Devolutions.AgentIdentity
2026.10.20
Prefix Reserved
Devolutions.Agent.Identity.Authority
Additional DetailsLegacy, superseded by package Devolutions.Agent.Identity.Authority
dotnet add package Devolutions.AgentIdentity --version 2026.10.20
NuGet\Install-Package Devolutions.AgentIdentity -Version 2026.10.20
<PackageReference Include="Devolutions.AgentIdentity" Version="2026.10.20" />
<PackageVersion Include="Devolutions.AgentIdentity" Version="2026.10.20" />
<PackageReference Include="Devolutions.AgentIdentity" />
paket add Devolutions.AgentIdentity --version 2026.10.20
#r "nuget: Devolutions.AgentIdentity, 2026.10.20"
#:package Devolutions.AgentIdentity@2026.10.20
#addin nuget:?package=Devolutions.AgentIdentity&version=2026.10.20
#tool nuget:?package=Devolutions.AgentIdentity&version=2026.10.20
Devolutions.AgentIdentity
Devolutions.AgentIdentity implements what every Agent Identity authority must do the same way under CONTRACT.agent-identity.md, so that its hosts, such as DVLS and PSU, cannot drift apart on the protocol that Devolutions Agent speaks.
The contract is docs/agent-identity/CONTRACT.agent-identity.md in the devolutions-gateway repository, which also holds this library.
What the library covers
- Enrollment tokens:
EnrollmentToken.Createcreates a token,FriendlyNameFormatparses and evaluates its friendly-name format, andEnrollmentAuthorization.ReadBearerTokenreads it from anenrollrequest. The host stores the token'sSecretHashand itsProductBaseUrl, never itsValue, which it shows once and never logs. - Request authentication:
RequestAuthenticatorverifies the signedrenew,confirmandcheck-inrequests and the signed opening of an agent channel stream (CONTRACT.agent-identity.md §6). - Request bodies:
EnrollRequestBody,RenewRequestBodyandCheckInRequestBodyparse a body and validate its CSR (CertificateSigningRequest) and metadata (AgentMetadata), andConfirmRequestBodychecks that aconfirmbody is empty. - Decisions:
EnrollmentRules.Decidedecides an enrollment andRenewalRules.Decidea renewal, given the issued certificate that holds the CSR key. - Certificates:
IdentityCertificateIssuer.Issueissues the certificate of the new identity that an enrollment decision creates, or of the identity that a renew request proved. - Responses:
IdentityConfigholds an identity's config, andResponseBodieswrites the bodies of thetrust-anchor,enroll,renewandcheck-inresponses. - Agent channel:
ChannelOpeningverifies the handshake that authenticates a stream, andChannelMessagesbuilds theConfigUpdate,RenewRequestedandReconnectmessages that the authority pushes. - Errors:
AgentIdentityErrorgives each error its code, HTTP status and message, and writes its JSON body and gRPC exception. - Encodings:
AgentIdentityProtocolcomputes certificate thumbprints and formats timestamps.
The host keeps everything else:
- Storage: identities, certificates and their statuses, the SPKI SHA-256 of every issued key, token hashes, revoked tokens included, accepted nonces, metadata, configs and request-renewal flags.
- Hosting: routes, TLS, the gRPC service and its timers, and when to send each push message.
- Management: tokens, identities and root rotation, and the permissions that guard them.
- The root CA and its private key at rest; the library reaches the key only through
IRootSigner.
Host dependencies
The host provides these dependencies, with the guarantees the library relies on:
ICertificateRegistryfinds, by thumbprint, a certificate that the authority issued, in any status includingRetired, or returns null when there is none or its identity was deleted. Index certificates byAgentIdentityProtocol.ComputeThumbprint, not by the SHA-1X509Certificate2.Thumbprint. EachRegisteredCertificateholds the DER exactly as issued, and the currentStatusandIdentityRevoked, so that a revocation takes effect from the next request.INonceStore.TryRecordAsyncrecords akeyidandnoncepair, or returns false when the pair is already recorded, in one atomic operation, and keeps the pair untilretainUntil.IRootSignerholds the root that issues new certificates and aSignatureGeneratorover its private key. ItsCertificatemust have a Subject Key Identifier extension, which becomes the Authority Key Identifier of each issued certificate; without one,IdentityCertificateIssuer.IssuethrowsArgumentException. The root must also fit the chain profile (CONTRACT.agent-identity.md §4): a P-256 or P-384 key, basicConstraints withcAset, key usage withkeyCertSign, and aSignatureGeneratorthat signs withecdsa-with-SHA256for a P-256 key orecdsa-with-SHA384for a P-384 key; otherwiseIssuethrowsArgumentException. Once a root rotation starts, it holds the new root (CONTRACT.agent-identity.md §8.3).TimeProvideris the authority's clock, usuallyTimeProvider.System, which decides signature freshness, certificate validity and handshake deadlines. Take thenowofEnrollmentRules.Decideand theserverTimeof every error from the same clock, since the agent corrects its own clock fromserver_time.RandomNumberGeneratormust be cryptographically secure, such asRandomNumberGenerator.Create(), since it supplies channel challenges, token secrets and certificate serial numbers.
The library puts this load on the registry and the nonce store:
- Each signed request that passes parsing, the tag and the time window costs one
FindByThumbprintAsync, and only one whose signature verifies also costs oneTryRecordAsync(CONTRACT.agent-identity.md §6). The thumbprint comes from the request, so an unauthenticated client can drive lookups with randomkeyids. - Each channel stream whose opening verifies costs two registry reads, one for the opening and one when the handshake ends, as CONTRACT.agent-identity.md §7.4 requires, and one nonce write.
To bear that load, it is safe to:
- look certificates up through an index on their thumbprint;
- cache a certificate's DER by thumbprint, since it never changes;
- answer misses from a bounded negative cache, or rate-limit them per client;
- index the nonce store by
keyidandnonce, and expire each entry at itsretainUntil.
Never cache Status or IdentityRevoked beyond one request, nor whether the identity still exists: a revocation, a deletion or a retirement takes effect from the next request only if every lookup reads them afresh.
RequestAuthenticator keeps no state between requests, so one instance can serve them all when its dependencies accept concurrent calls.
Exceptions from the registry and the nonce store propagate to the caller.
Call order
Each HTTP request goes through the body size check, then authentication, then body parsing, so that the error precedence of CONTRACT.agent-identity.md §5.4 holds: a body over 64 KiB fails first, and an authentication error wins over an invalid body, CSR or metadata.
When a step fails, answer with the Error of its result, as Errors describes.
trust-anchor
Answer with ResponseBodies.TrustAnchor, given the published roots: one normally, the old and the new during a rotation (CONTRACT.agent-identity.md §5.1).
enroll
- Answer a body longer than
AgentIdentityProtocol.MaxRequestBodyByteswithAgentIdentityError.BodyTooLarge. - Pass the
Authorizationfield lines, as received, toEnrollmentAuthorization.ReadBearerToken. Find the stored token by the result'sSecretHash, revoked tokens included, and answerAgentIdentityError.From(AgentIdentityErrorCode.TokenInvalid)when there is none. - Parse the body with
EnrollRequestBody.Parse. - Look up
Csr.SubjectPublicKeyInfoSha256among the keys of every certificate the authority has issued, deleted identities included. Describe the match withIssuedKeyMatch.ForIdentityorIssuedKeyMatch.ForDeletedIdentity, or use null when there is none. - Pass the token's
EnrollmentTokenState, with theProductBaseUrlstored for it, the body'sCsr, the match and the current time toEnrollmentRules.Decide, and act on itsOutcome; a CSR that names no enrollment URL, or not the token's, getstoken_url_mismatchbefore any key rule applies (CONTRACT.agent-identity.md §2.1):Reject: answer withError.ReturnExistingIdentity: take identityExistingIdentityId, and change nothing, not even itslast_seen_at, since a replay proves no current possession of the device key.CreateIdentity: takeNewIdentityId, which the library minted, as the identity's ID, issue its certificate with theIdentityCertificateIssuer.Issueoverload that takes the decision, which names the decision'sEnrollmentUrlin it, name it withFriendlyNameFormat.Evaluate, create its config withIdentityConfig.Create, and store it with its metadata, itsEnrollmentUrl, the key'sSubjectPublicKeyInfoSha256, and the current time as itslast_seen_at. Create the identity and count the token use atomically, as CONTRACT.agent-identity.md §2.1 requires of concurrent enrollments.
- Answer with
ResponseBodies.Enroll, given the authority ID and the ID taken in step 5, with that identity's current certificate chain, leaf first, and config (CONTRACT.agent-identity.md §5.2).
Signed requests
renew, confirm and check-in start alike:
- Build a
SignedHttpRequestfrom the method, theSignature-Input,SignatureandContent-Digestfield lines, and the body bytes, all as received. - Pass it to
RequestAuthenticator.AuthenticateAsyncwithAgentOperation.Renew,AgentOperation.ConfirmorAgentOperation.CheckIn. It answers a body longer thanAgentIdentityProtocol.MaxRequestBodyByteswithAgentIdentityError.BodyTooLargebefore it checks the signature. - Parse a
renewbody withRenewRequestBody.Parse, aconfirmbody withConfirmRequestBody.Parseand acheck-inbody withCheckInRequestBody.Parse, and answer an invalid body with theErrorof the parse result.
Each then acts for the result's Identity only.
renew
- Look up the CSR key as for
enroll, passing false forenrolledWithPresentedToken. - Pass
Identityand the match toRenewalRules.Decide, and act on itsOutcome:Reject: answer withError.ReturnPendingCertificate: keep the identity's pending certificate, which holds the key.IssuePendingCertificate: issue a certificate with theIdentityCertificateIssuer.Issueoverload that takes theAuthenticatedIdentity, passing theEnrollmentUrlstored with the identity so that every certificate of the identity names the same URL, and store it as the identity's pending certificate, retiring the previous one; the renew CSR's own enrollment URL is ignored (CONTRACT.agent-identity.md §4). Store it atomically, as CONTRACT.agent-identity.md §2.1 requires of concurrent renewals.
- Replace the identity's metadata, set its
last_seen_atto the current time, and answer withResponseBodies.Renew, given the pending certificate's chain (CONTRACT.agent-identity.md §5.3).
confirm
Parse the body with ConfirmRequestBody.Parse, and answer a non-empty body with the result's Error before changing any state (CONTRACT.agent-identity.md §5.5).
When Identity.CertificateStatus is CertificateStatus.Pending, make the certificate current, retire the previous current certificate, and clear the request-renewal flag if it was set before the certificate was issued (CONTRACT.agent-identity.md §8.2).
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).
Set the identity's last_seen_at to the current time and answer 204, also when the certificate was already current (CONTRACT.agent-identity.md §5.5).
check-in
Replace the identity's metadata, set its last_seen_at to the current time, and answer with ResponseBodies.CheckIn, given the identity's config and its request-renewal flag (CONTRACT.agent-identity.md §5.6, §8.2).
Agent channel
Set the gRPC MaxReceiveMessageSize and MaxSendMessageSize options of the AgentChannel service to 65,536 bytes, the message limit of CONTRACT.agent-identity.md §7.1.
The send limit is only a backstop, since IdentityConfig.Create refuses a config whose ConfigUpdate message would exceed it.
- Pass the request method and the values of the
signature-inputandsignaturerequest headers, in received order, toRequestAuthenticator.AuthenticateChannelOpeningAsync. - Send the result's
Opening.Challengeas the first message on the stream. - Before any call of step 4, add the stream to the live streams that the revocation and deletion paths close, under
Opening.CertificateThumbprintsince its identity is still hidden, and remove it if the handshake fails. - Pass the first agent message to
Opening.VerifyHelloAsync. CallOpening.HelloTimedOutAsyncinstead when no message arrives withinChannelHandshake.HelloTimeout, whenOpening.CertificateNotAfterpasses first, or when the agent ends the stream without a message. CallOpening.FirstMessageUnreadableAsyncinstead when the first message cannot be decoded, for example because it exceedsMaxReceiveMessageSizeor a protobufstringholds invalid UTF-8 (CONTRACT.agent-identity.md §7.1, §7.3 step 4). The first of these calls ends the handshake, and a second one throwsInvalidOperationException. - On success, replace the identity's metadata with
Metadata, set itslast_seen_atto the current time, sendWelcome, and only then treat the stream as authenticated forIdentity. Setlast_seen_atagain on every later agent message, but not when the stream closes, since a closure proves no possession of the device key (Configuration and metadata in INTENT.agent-identity.md). - Send
ChannelMessages.RenewRequestedif the identity's request-renewal flag is set, andChannelMessages.ConfigUpdateif theRevisionof its config is higher thanapplied_state_versions["config"]of theHello(CONTRACT.agent-identity.md §7.3 steps 5 and 7). - If
Identity.CertificateStatusisCertificateStatus.Retired, the certificate was retired during the handshake: also sendChannelMessages.Reconnect(ReconnectReason.CertificateRotated), and close the stream withOK60 s later, as forconfirm(CONTRACT.agent-identity.md §7.4). - Close the stream no later than
Identity.CertificateNotAfterwithAgentIdentityErrorCode.CertificateExpired, and when the identity is revoked withAgentIdentityErrorCode.IdentityRevoked(CONTRACT.agent-identity.md §7.4). - When a later agent message cannot be decoded or exceeds
MaxReceiveMessageSize, close the stream withAgentIdentityError.From(AgentIdentityErrorCode.InvalidRequest).ToRpcException(serverTime)(CONTRACT.agent-identity.md §7.4).
VerifyHelloAsync, HelloTimedOutAsync and FirstMessageUnreadableAsync read the registry again, so a deletion, a revocation or the certificate's expiry since the opening closes the stream with its own error, ahead of the handshake failures (CONTRACT.agent-identity.md §7.4).
Step 3 lets no revocation or deletion slip past that read: one that commits before the read fails the handshake, and one that commits after it finds the stream among the live streams.
Without step 3, a stream whose identity is revoked just after the read would stay authenticated until Identity.CertificateNotAfter.
A channel opening has no body: VerifyHelloAsync then checks the deadline, the message and its proof, and the metadata of the Hello last (CONTRACT.agent-identity.md §7.3 step 4).
When an identity's config changes, create its IdentityConfig again with a higher Revision, and send ChannelMessages.ConfigUpdate on each authenticated stream of the identity.
ChannelMessages.RenewRequested takes RenewalRequestReason.Rotation for the notifications of a root rotation (CONTRACT.agent-identity.md §8.3), and RenewalRequestReason.Admin for an administrator's request.
Errors
Over HTTP, every non-2xx response under {u}/api/agent-identity/v1/ must carry an error body, including those of the web framework (CONTRACT.agent-identity.md §5.4).
Answer with status HttpStatusCode, header Content-Type: application/json and body ToJsonBody(serverTime).
For a failure that the library does not report, use AgentIdentityError.From with its code, or one of these errors:
| Failure | Error |
|---|---|
| Path that no endpoint serves | UnknownRoute, 404 |
| Method that the endpoint does not accept | MethodNotAllowed, 405 |
| Body that the web framework cannot read | MalformedBody, 400 |
| Body larger than 64 KiB | BodyTooLarge, 413 |
| Unexpected exception | UnexpectedFailure, 500 |
| Overload or a missing dependency | ServiceUnavailable, 503 |
The library writes no headers, so the host adds Retry-After to ServiceUnavailable when it sheds load.
Over gRPC, end the call by throwing ToRpcException(serverTime), which carries the status of ToGrpcStatusCode and the trailers error-code and server-time (CONTRACT.agent-identity.md §7.4).
For a closure that the host decides, such as a revocation, build the error with AgentIdentityError.From.
Log FailureDetail beside the error: it names the rule that the request broke and never holds a value taken from the request, such as a metadata key or value, a header, signature data or a token.
Authenticated identities
Only the signature check, RequestAuthenticator.AuthenticateAsync, and the channel handshake, ChannelOpening.VerifyHelloAsync, create an AuthenticatedIdentity, which has no public constructor.
Code that acts for an identity should take one, so that it acts for the identity a signature proved, never for an identity ID that the agent or a caller sent.
The library does so itself: RenewalRules.Decide and the renewal overload of IdentityCertificateIssuer.Issue take the renewing AuthenticatedIdentity, and throw ArgumentException for one authenticated for another operation.
The certificate of a new identity is issued from the EnrollmentDecision that creates it, which only EnrollmentRules.Decide produces, under the NewIdentityId that it minted; the overload that takes the decision throws ArgumentException for one of another outcome.
No IdentityCertificateIssuer.Issue overload takes an identity ID.
A verified channel opening yields only a ChannelOpening, whose identity stays hidden until the agent signs the stream's challenge, since signed opening headers are not tied to one stream and can be captured by a TLS-terminating proxy, TLS inspection or a header log (security invariant 7 of INTENT.agent-identity.md).
For the same reason, AuthenticateAsync throws ArgumentOutOfRangeException for AgentOperation.Connect.
Build, test and pack
From the repository root, with the .NET 10 SDK:
pwsh ./dotnet/AgentIdentity/build.ps1
dotnet test ./dotnet/AgentIdentity/AgentIdentity.sln
build.ps1 builds the library in Release and packs it, with its symbol package, into dotnet/AgentIdentity/Devolutions.AgentIdentity/bin/Release/.
The tests also check the library against the shared vectors in docs/agent-identity/test-vectors.json.
The package depends on Devolutions.Agent.Channel, the gRPC types generated from the channel schema (CONTRACT.agent-identity.md §7.1): the handshake takes its AgentMessage and returns its ServerMessage.
Implement the AgentChannel service with that package; a feed that serves this package must serve that one too.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
- Devolutions.Agent.Channel (>= 2026.9.25)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated | |
|---|---|---|---|
| 2026.10.20 | 116 | 10/26/2026 |