Skip to content

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

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

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

Conversation

@CBenoit

Copy link
Copy Markdown
Member

Add Devolutions.AgentIdentity, the .NET library that holds 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 and the root key, and supplies a certificate registry, a nonce store, a root signer, a clock and a random number generator.

The library implements:

  • request authentication per contract §6, steps 1 to 6 in order: a strict RFC 8941 parser that refuses repeated members, parameters and labels; only canonical base64, keyid and nonce encodings; Content-Digest; and a nonce recorded only after the signature verifies;
  • the agent channel handshake of §7.3 and §7.4: a fresh challenge per stream, the proof, a registry re-read, and the order of Hello failures and stream deadlines;
  • enrollment tokens, their secret hash and the enrollment decision of §2.1;
  • metadata, request body and CSR validation;
  • identity certificates issued through the host's root signer, and the renewal decision of §5.3;
  • the agent-facing response bodies, the identity config and the channel's push messages;
  • the error bodies and gRPC statuses of §5.4 and §7.4.

Only the signature check and the channel handshake create AuthenticatedIdentity, the type through which code acts for an identity.
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 test vector.

The package lives in dotnet/AgentIdentity/ with a README for host developers, is built and tested in the .NET library CI job, and is published by publish-libraries.yml next to Devolutions.Agent.Channel, on which it depends.

Issue: DGW-479

Co-authored-by: Copilot App 223556219+Copilot@users.noreply.github.com

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>
Every authority must accept and reject the same metadata, whether it
arrives in a request body or in a channel Hello, so the limits of the
contract live in one place.

Add AgentMetadata, a read-only dictionary that only validation creates,
and AgentMetadata.Validate, which enforces the key pattern, the key
count, the value length in UTF-8 bytes, the control-character ban and
the 16 KiB total, and rejects repeated keys and strings that are not
valid Unicode. A failure carries invalid_request and a detail for host
logs that never quotes an invalid key.

Issue: DGW-479
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
An authority takes only the public key from an agent's CSR, after the
CSR proves possession of it, and each key must have exactly one
SubjectPublicKeyInfo so that its SHA-256 identifies it among every key
the authority has issued.

Add CertificateSigningRequest.Validate, which accepts a DER PKCS #10
request of version 0 without trailing bytes, whose key is an
uncompressed point on the named curve P-256 and whose self-signature is
ecdsa-with-SHA256 without parameters over the exact request info. The
subject and attributes are read only as DER elements of their type. The
validator checks the curve equation itself, since the platform throws a
platform-specific exception for a point off the curve, and rejects an r
or s that DER makes negative, which the platform verifier accepts as
unsigned. The result keeps the exact SubjectPublicKeyInfo and its
SHA-256.

Issue: DGW-479
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
The authority must classify a request body, its CSR and its metadata
the same way on every endpoint, and must read the same values as any
proxy, log or second parser would.

Add EnrollRequestBody, RenewRequestBody and CheckInRequestBody, whose
Parse methods share one reader. It accepts one JSON object without
comments or trailing commas, rejects a member name repeated at any
depth, including inside unknown members, and a string that is not
Unicode text, then reads csr as canonical base64 into a validated CSR
and metadata through AgentMetadata.Validate. A null member counts as
absent and unknown members are ignored. Every failure is
invalid_request with a detail for host logs.

Issue: DGW-479
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Every authority must issue the same leaf profile, so that an agent and
any relying party read an identity the same way whichever authority
issued it.

Add IRootSigner, through which the host lends its root certificate and a
signature generator without exposing the root key, and
IdentityCertificateIssuer.Issue. The leaf holds only the CSR's key, with
CN and SAN URI built from the identity ID, clientAuth, a critical
digitalSignature key usage, a subject key identifier, an authority key
identifier equal to the root's, and no basic constraints. It is valid
from the clock truncated to the second for the lifetime, 90 days by
default, capped at the root's notAfter, and its serial number is 16
random octets with the top bit cleared, drawn again if zero.

A lifetime under one second, a root that expires by notBefore, or a
root without a subject key identifier throws.

Issue: DGW-479
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Every authority must write the same dvaet1 token format, so that one
agent enrolls with any of them.

Add EnrollmentToken.Create, which writes dvaet1.<bag>.<secret>. The bag
holds the product base URL in normalized form and without trailing
slashes, since the agent appends the API path to it. The secret encodes
32 bytes from the injected generator, and SecretHash, which the
authority stores instead of the token, is the SHA-256 of the secret
text. Create rejects a URL that is not an absolute https URL, carries
user information, a query or a fragment, or makes the token longer than
4096 bytes. ToString and exception messages never reveal the token.

Issue: DGW-479
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Every authority must refuse the same enroll credentials, because the
agent treats token_invalid as permanent and never retries that token.

Add EnrollmentAuthorization.ReadBearerToken, which returns the hash
under which the authority finds the token, or token_invalid when the
Authorization field is missing, repeated, not Bearer, or holds no
dvaet1 token of at most 4096 bytes. As RFC 9110 defines credentials,
the scheme is case-insensitive and one or more spaces precede the
token. The bag is not decoded, since the authority finds a token by its
secret alone, and the hash covers the secret text, so a non-canonical
secret matches no stored token. The failure detail for logs is a fixed
sentence that never holds any part of the token.

Issue: DGW-479
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Every authority must answer an enroll request the same way, because
the agent retries a lost enrollment response with the same token and
CSR, and treats every token_* code as permanent.

Add EnrollmentRules.Decide, which applies the contract's precedence to
the stored token state and to the issued certificate that holds the
CSR key, if any. The current key of a non-revoked identity enrolled
with the same token replays that identity without counting a use, even
with a deleted, expired or exhausted token. Any other issued key gets
the first matching key rule, or token_invalid with a deleted token; the
key of a deleted identity gets identity_revoked, since the authority
keeps only its SPKI hash and issuance time. A new key gets
token_invalid with a deleted token, then token_expired, then
token_exhausted, and otherwise creates an identity.

The host supplies these facts through EnrollmentTokenState and
IssuedKeyMatch, with the CertificateStatus of the matching certificate.

Issue: DGW-479
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Every authority must name a new identity the same way from its token's
friendly-name format, and reject the same formats when a token is
created.

Add FriendlyNameFormat. TryParse accepts literal text, {{ and }}, and
the placeholders of the known metadata keys and {token_name}, and
explains an unpaired brace or an unknown placeholder to the
administrator. Evaluate substitutes the values, with a missing metadata
value empty, trims Unicode White_Space, truncates to 255 Unicode scalar
values without splitting one, trims again, and falls back to the
identity ID when the name is empty. A lone surrogate becomes U+FFFD, so
the name stays well-formed. Default is {hostname}.

Issue: DGW-479
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
A default-initialized CertificateStatus was Pending, so a host that
never set a status passed IssuedKeyMatch.ForIdentity a valid one.

Number the statuses from 1, like the library's other public enums, so
that an uninitialized status is undefined and ForIdentity rejects it.

Issue: DGW-479
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
A friendly-name format is administrator input that may hold a pasted
enrollment token, and tokens never appear in logs, not even partially.
A parse error repeated an unknown placeholder, and ToString returned
the whole format, so either could carry a token into a log.

A parse error now says what is wrong and at which character, counted
in Unicode scalar values from 1, and lists the valid placeholders,
without any part of the format text. ToString returns the type name,
and the host stores the text that it parsed.

Issue: DGW-479
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Certificate statuses start at 1, so 3 is Retired, a defined status.
The test for an undefined status checks the default value, which an
uninitialized status holds, and 4, which follows the last status.

Issue: DGW-479
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
FriendlyNameFormat.Evaluate took any string dictionary, so a host could
name an identity from metadata that never passed the protocol's limits
on keys and values.

Evaluate now takes AgentMetadata, which only validation and the request
body parsers create.

Issue: DGW-479
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
The FriendlyNameFormat remarks said that no member returns any part of
the format text, but Evaluate returns its literal text as part of the
friendly name. Limit the guarantee to parse errors and ToString.

Issue: DGW-479
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
The contract now has the server ignore a CSR's attributes, including an
extensionRequest with an empty Extensions list, and the valid CSR vector
will be regenerated with an empty attribute set.

Add tests, built without the vector file, that accept a CSR whose
attributes hold an empty extensionRequest and one with an empty
attribute set. The tests that alter the valid vector now check that it
is accepted and locate its outer length with System.Formats.Asn1, so
they hold no octet or length of it. The non-canonical base64 tests use
requests built to have pad bits and both + and /, which a regenerated
vector may lack.

Issue: DGW-479
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
A signed channel opening never authenticates a stream on its own, so
every authority must check the agent's first message against the
stream's challenge the same way before it acts for the identity.

ChannelOpening.VerifyHello requires a Hello whose correlation_id is the
Challenge id and whose proof is a raw 64-byte P-256 signature, by the
key of the certificate that opened the stream, over the proof domain, a
zero byte, the challenge and the connect nonce. A message failing any
of these is signature_invalid, as is HelloTimedOut, which the host
calls when no message arrives within ChannelHandshake.HelloTimeout of
10 s; only then does invalid metadata draw invalid_request. Success
yields the identity of the opening certificate for the connect
operation, the validated metadata, and a Welcome that answers the Hello
with the server time. The first call to either method ends the
handshake, and any later call throws.

Issue: DGW-479
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
A stream closes no later than the notAfter of the certificate that
opened it, and the agent must answer the challenge within 10 s, so the
handshake must fail once either deadline has passed, whatever the first
message holds, and the host must know when the certificate expires
before the stream is authenticated.

VerifyHello and HelloTimedOut read the clock once and return
certificate_expired after the certificate's notAfter. VerifyHello then
returns signature_invalid when it runs more than HelloTimeout after the
opening was verified, before it reads the message; exactly 10 s is in
time. The opening records that instant from the authority's clock once
verification ends, so time spent in the registry and the nonce store
does not shorten the agent's window. ChannelOpening.CertificateNotAfter
lets the host close a stream still waiting for its Hello at that time.
The Welcome carries the same clock reading.

Issue: DGW-479
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
The identity of a stream stays hidden until its proof verifies, so the
host cannot close a stream still in its handshake when its identity is
revoked or deleted or its certificate retired, and would authenticate
it anyway, although revocation must block authentication.

ChannelOpening.VerifyHelloAsync replaces VerifyHello. It reads the
clock, then the certificate registry once, and checks in the order of
the signed-request rules: a certificate no longer registered is
identity_unknown and a revoked identity identity_revoked, then come
expiry, the Hello deadline, the message and the metadata as before. A
certificate that a confirm retired during the handshake still
authenticates and is reported as retired, so the host reconnects the
stream as for any retired certificate rather than send a healthy agent
the terminal identity_unknown. The handshake ends before the registry
lookup, and a registry answer for another certificate or identity
throws, as in request authentication. HelloTimedOut is unchanged.

Issue: DGW-479
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
The shared vectors now give the private key of the valid CSR and a
Content-Type field on some requests. The loader rejects unknown members,
so that a change it does not model is noticed, and failed to load the
file. Read both as optional members, typed like the fields beside them;
the signature profile does not cover Content-Type.

Issue: DGW-479
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
The agent appends a path both to the product base URL of an enrollment
token and to the agent channel URL of a config, and the contract states
the same rules for both: an absolute https URL without a query or
fragment.

Move the token's URL check and normalization into an internal BaseUrl
helper, unchanged, so that both URLs follow one implementation.

Issue: DGW-479
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
The enroll and check-in responses and the ConfigUpdate message all carry
the identity's config, so every authority must write it the same way.

IdentityConfig.Create takes the revision, an optional agent channel URL
and optional product-specific fields, and fails with ArgumentException
for anything the contract rejects. The URL must be an absolute https URL
without user information, query or fragment, and is written without
trailing slashes, since the agent appends the service path to it. A
product field has a snake_case name that no contract member has, and a
value other than null. ToUtf8Json writes version 1, the revision as an
unsigned 64-bit integer, the URL only when present, and the product
fields. The config is written once at creation, so that a value UTF-8
JSON cannot carry fails there rather than in a response.

Issue: DGW-479
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
IssuedKeyMatch.ForDeletedIdentity said that the key of a deleted
identity gets identity_revoked whatever token enrolled it, but a deleted
presented token gets token_invalid first, since it authenticates nothing
but a replay. Say so.

AgentIdentityError.MalformedBody did not say when a host uses it, though
the body parsers return their own invalid_request. It is for a body that
the host's framework cannot read or hand to the parsers, such as one
that is not UTF-8 application/json.

Issue: DGW-479
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
The trust-anchor, enroll, renew and check-in responses are agent-facing,
so every authority must write them the same way.

ResponseBodies writes each one as a UTF-8 JSON object with exactly the
members of the contract. A certificate is its DER in canonical base64,
and each published root also carries its thumbprint and its validity as
RFC 3339 UTC timestamps. IDs are lowercase hyphenated UUIDs, and the nil
UUID is refused, since it stands for no ID. A certificate chain must be
leaf first: the issuer of each certificate must be the subject of the
next, which RFC 5280 makes byte for byte equal. The config is written as
IdentityConfig wrote it, and renewal_requested as a JSON boolean.

Issue: DGW-479
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
ConfigUpdate, RenewRequested and Reconnect carry agent-facing values on
the agent channel, so every authority must build them the same way.

ChannelMessages builds each one as a ServerMessage with a fresh
lowercase hyphenated UUID id and no correlation_id. ConfigUpdate
carries the JSON object that IdentityConfig writes in config_json.
RenewRequested carries the reason admin or rotation, and Reconnect the
reason certificate_rotated, each from an enum whose values start at 1,
so an undefined reason throws.

Issue: DGW-479
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
A renewal certifies a new key for an existing identity, but the only
issuance method took a Guid, so a host could issue a renewal for an
identity that a request merely named.

IdentityCertificateIssuer.Issue now has an overload that takes the
AuthenticatedIdentity of a renew request, which only the signature
check creates, and throws ArgumentException for an identity
authenticated for another operation. The Guid overload is documented as
the one for the identity that the authority mints at enrollment.

Issue: DGW-479
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
The contract gives a renew request one outcome per CSR key, and an
authority that answers differently could hand out another identity's
pending chain or reuse a public key, so every authority must apply the
rules the same way.

RenewalRules.Decide takes the AuthenticatedIdentity of the renew request
and the IssuedKeyMatch of the CSR key, or null for a key never issued.
The pending key of that identity returns its pending chain, so a device
recovers a lost response. Any other issued key gets invalid_request:
the identity's current or retired key, any key of another identity,
revoked or not, and any key of a deleted identity. A key never issued
gets a new pending certificate, which retires the previous one. An
identity authenticated for another operation throws ArgumentException.

Issue: DGW-479
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
The certificate list check of ResponseBodies took a description only to
vary two messages, which the parameter name already tells apart. The
body tests repeated the reading of the roots and of the chain.

Drop the description and give the tests one helper for each array.

Issue: DGW-479
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
A config is immutable, and a response body must write the certificates
that it checked, but no test caught a ToUtf8Json that returned the
config's own buffer or a writer that read the caller's certificates a
second time.

Check that changing the returned JSON leaves the config unchanged, and
that the trust-anchor, enroll and renew writers read their certificates
once.

Issue: DGW-479
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
The handshake now looks the opening certificate up again, so
ChannelOpening.VerifyHello became VerifyHelloAsync, and the helper that
authenticates a test identity through a channel no longer compiled.

Await VerifyHelloAsync in that helper.

Issue: DGW-479
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
IdentityConfig checked only the names of the product-specific fields, so
a value such as {"maxSessions": 4} reached the enroll and check-in
responses and ConfigUpdate, against the snake_case keys of the
contract's protocol conventions, and a value could repeat a member name,
which two parsers may read differently.

Create now walks each value, through nested objects and arrays, before
writing it. Every member must have a snake_case name that no other
member of its object has, names compared after unescaping, and a value
other than null, since an absent field is omitted. An array element may
still be null. The walk keeps its own stack, so a deep value cannot
exhaust the thread's stack.

Issue: DGW-479
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
DVLS and PSU embed the library to serve the agent-facing protocol, and
using it correctly takes facts that no single type documents: what
stays with the host, the guarantee each dependency must keep, and the
order of the size check, authentication and body parsing on which the
contract's error precedence depends.

The README covers these, the HTTP and gRPC error responses, why only
the signature check and the channel handshake create an
AuthenticatedIdentity, and how to build, test and pack the package. It
links the contract sections instead of restating them.

Issue: DGW-479
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
NuGet shows a package's README on its gallery page and in IDEs, and
pack warns about a package without one.

PackageReadmeFile names the README, and a packed None item places it
at the root of the package.

Issue: DGW-479
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
The library now writes the response bodies, decides renewals, issues
them for the proved identity, builds the channel push messages and
re-reads the registry during the channel handshake, so a host that
follows the README must call these rather than reimplement them.

Each endpoint now has its own steps: the trust-anchor, enroll, renew
and check-in responses come from ResponseBodies, a renewal goes through
RenewalRules.Decide and the Issue overload that takes the renewing
AuthenticatedIdentity, confirm reconnects the streams of the retired
certificate, and the channel uses VerifyHelloAsync, treats a
certificate retired during the handshake like any retired one, and
pushes RenewRequested and ConfigUpdate. The advice to re-check
revocation after the handshake is gone, since VerifyHelloAsync does it.

Issue: DGW-479
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
CONTRACT.md §7.3 steps 5 and 7 send RenewRequested and ConfigUpdate
after every Welcome they apply to. §7.4 adds Reconnect and the closure
60 s later for a stream whose certificate was retired during the
handshake, but exempts that stream from neither message, and the
README skipped both for it.

The channel steps now reconcile every authenticated stream, and handle
a retired certificate in a separate step that also applies.

Issue: DGW-479
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
NuGet's README renderer drops repository-relative links, and the
contract and intent files are not in the package, so every contract
and intent link in the packed README led nowhere.

The link definitions now point at those files on the master branch on
GitHub, with the same section anchors. The package is published from
master, so the paths resolve there.

Issue: DGW-479
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
A revocation, a deletion and the certificate's notAfter close a stream
still awaiting its Hello ahead of every failure of the handshake, the
timeout included, so a stream whose identity was revoked while it
waited must close with identity_revoked rather than signature_invalid.

ChannelOpening.HelloTimedOutAsync replaces HelloTimedOut. Like
VerifyHelloAsync, it ends the handshake at once, reads the clock and
then the certificate registry once, and returns identity_unknown for a
certificate no longer registered, identity_revoked for a revoked
identity and certificate_expired after notAfter, before it falls back
to signature_invalid. A registry answer for another certificate or
identity throws. Both methods share the clock and registry reading and
these closures. The docs now say that the certificate of a stream may
be retired during the handshake by a renew as well as a confirm, and
leave its treatment to the contract.

Issue: DGW-479
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
The Hello timeout is ChannelOpening.HelloTimedOutAsync, which re-reads
the certificate registry like VerifyHelloAsync, so a deletion, a
revocation or the certificate's expiry closes a stream still awaiting
its Hello with that error rather than as a timeout.

The channel steps call HelloTimedOutAsync, and the README says that
both handshake methods read the registry again before any handshake
failure.

Issue: DGW-479
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.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.

A project root that holds every source file buries its .csproj among
dozens of files and scatters each contract area through an
alphabetical list.

Each project root now shows its .csproj beside one folder per contract
area: Errors, Protocol, Enrollment, Bodies, Certificates,
Authentication and Channel, plus Support for the shared test helpers.
The files move unchanged and keep their namespace, so the public API
stays in the single Devolutions.AgentIdentity namespace and a host
still needs one using directive.

Issue: DGW-479
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
@CBenoit
Benoît Cortier (CBenoit) added this pull request to stack #2126 October 20, 2026 14:01

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.

1 participant