Skip to content

feat(agent): device identity enrollment and channel - #2000

Draft
Benoît Cortier (CBenoit) wants to merge 55 commits into
masterfrom
cbenoit-agent-identity-v1
Draft

Benoît Cortier (CBenoit) wants to merge 55 commits into
masterfrom
cbenoit-agent-identity-v1

Conversation

@CBenoit

@CBenoit Benoît Cortier (CBenoit) commented Sep 24, 2026 •

Copy link
Copy Markdown
Member

Devolutions Agent can obtain its own device identity from a management server such as Devolutions Server: a server-issued device ID and an X.509 certificate whose private key never leaves the machine.
One reusable enrollment token, deployable through msiexec or Microsoft Intune, enrolls any number of machines.
After enrolling, the agent renews its certificate on its own and keeps an authenticated, bidirectional gRPC channel to the server, so the server can reach the agent (for example, to ask it to renew).

This draft contains the product-neutral contract and its conformance suite; the agent implementation follows in the same PR:

  • docs/agent-identity/CONTRACT.md: the agent-facing HTTP API, the RFC 9421 request-signature profile, the gRPC channel and its proof-of-possession handshake, and the agent's local files and configuration.
  • docs/agent-identity/test-vectors.json: shared signature, channel-proof and CSR vectors for every implementation.
  • agent-identity-channel-proto: the channel schema for Rust, also published as the Devolutions.AgentIdentity.Channel NuGet package for server implementers.
  • agent-identity-mock and agent-identity-conformance: a mock server and a conformance tester that checks a server (the mock, or a real DVLS) and the agent against the contract; the testsuite runs both.

@CBenoit

Copy link
Copy Markdown
Member Author

Implementation notes (Phase 1: contract and conformance suite)

Contents

  • docs/agent-identity/CONTRACT.md v0.3 (approved), including the §10.1 clarification that extra_trusted_root is a PEM bundle.
  • docs/agent-identity/test-vectors.json, produced by a throwaway generator that builds the signature bases by hand and signs deterministically (RFC 6979).
    It was cross-checked with Python cryptography and includes RFC 9421 Appendix B.2.4.
  • agent-identity-channel-proto: tonic/prost code generated with vendored protoc, passed through prost_build::Config::protoc_executable rather than set_var.
    The same .proto builds the Devolutions.AgentIdentity.Channel NuGet package (net10.0, Google.Protobuf + Grpc.Core.Api).
    CI builds the package and publish-libraries.yml publishes it; the new package ID may need nuget.org trusted-publishing setup.
  • agent-identity-mock: the conformance oracle.
    It verifies the RFC 9421 profile by hand with p256, without an RFC 9421 library, applying the §6 checks in contract order and committing each nonce only after its signature verifies.
    Authenticated handlers take an AuthenticatedDevice that only the verifier can construct.
    A unit test runs every entry of test-vectors.json through the server's own verifier.
  • agent-identity-conformance: a custom harness that exits non-zero on failure.
    • Protocol tests act as a raw client, with their own signer written independently of the mock.
    • Agent tests spawn devolutions-agent run with DAGENT_CONFIG_PATH and stand in for the MSI through the pending file (Unix 0600; Windows DPAPI machine scope with a protected DACL).
    • Observation order: admin API first, agent files second, logs last.
    • Mock-only tests are skipped with --target dvls.
    • Rotation tests against DVLS need --disposable-dvls-target.
  • The testsuite starts two mocks under /mock and runs the tester against them.

Current results (Windows, local)

  • 47/47 protocol tests pass against the mock (about 15 s).
  • Expected to fail until the agent implementation lands: the 22 a_* tests below, which need devolutions-agent identity support.
    a_file_backend_permissions is Unix-only and skips on Windows.
    Until then, the testsuite test agent_identity::conformance is red in CI; fmt, clippy, the existing tests and the protocol tests are green.
    • a_pending_file_enroll_success, a_pending_file_deleted_on_permanent_error, a_pending_file_kept_on_transient_error, a_token_never_logged
    • a_same_token_no_enrollment, a_same_token_rejected_identity_no_enrollment, a_different_token_replaces_identity, a_cli_identity_enroll_writes_pending_file
    • a_renewal_happy_path, a_renewal_lost_response_retried, a_channel_connected_and_metadata, a_make_before_break_on_renewal
    • a_request_renewal_connected, a_request_renewal_while_offline, a_reconnect_make_before_break, a_revocation_stops_agent
    • a_device_unknown_recorded, a_no_channel_when_absent, a_multi_authority, a_key_non_exportable
    • a_rotation_migrates_connected_agent, a_rotation_migrates_on_schedule

Dependencies

  • The workspace resolves p256 0.14.0-rc.14 and ecdsa 0.17.0-rc.22, because ironrdp-connector 0.10 → sspi 0.21 → picky =7.0.0-rc.25 pins those release candidates.
    The mock and tester therefore pin p256 =0.14.0-rc.14.
  • The agent's signing layer, in the next phase, holds httpsig at =0.0.24 for the same reason.
    httpsig is used only to build and sign the agent's own requests, never to parse incoming headers, because 0.0.24 can panic on malformed Signature-Input.
  • Follow-up: once IronRDP releases on sspi 0.22 (picky rc.26), bump the picky pins to rc.26 and httpsig to 0.0.26, and drop the duplicate p256/ecdsa generation.

Suite decisions

  • Enroll idempotence is checked before token expiry and exhaustion, so a lost response on a maxUses = 1 token can be retried.
  • Chain checks:
    • The last chain element must equal a published root byte for byte.
    • Links are checked by name, and their signatures are verified for P-256 issuers.
    • Validity is checked at evaluation time.
    • There's no full RFC 5280 path validation (§4, invariant 10).
  • Windows test cleanup deletes a machine key only when the key didn't exist before the test, its name matches the contract pattern, and its authority is the server-issued authority_id.

Review
Each suite task went through a coder ↔ reviewer loop using the repository code-review skill.
The tester task ended after 3 rounds (19 → 14 → 8 findings); a follow-up fix task addressed the remaining 8 and was approved in round 2.

Note

LLM-assisted content (no human feedback).

@CBenoit
Benoît Cortier (CBenoit) force-pushed the cbenoit-agent-identity-v1 branch 2 times, most recently from eb39f01 to cbcb825 Compare October 19, 2026 16:40
Devolutions Agent gains an identity per authority (e.g.: DVLS, PSU):
the device enrolls once from a reusable token, the authority mints an
identity and issues an X.509 certificate, and the agent authenticates
later requests and a bidirectional gRPC channel with that identity.

- `docs/agent-identity/CONTRACT.md` defines what the agent and an
  authority agree on, with shared test vectors and an independent
  vector verifier; the design rationale lives in the shared intent
  `INTENT.agent-identity.md`, symlinked into each component.
- `device-key` keeps device keys in the Windows software provider or in
  files, under an access rule granting only the caller and SYSTEM.
- `agent-identity` and `agent-identity-httpsig` hold pending enrollment,
  stored identities, the REST client and RFC 9421 request signing;
  `agent-channel-proto` defines the channel for Rust and .NET.
- `devolutions-agent identity enroll` writes a pending enrollment, and
  the service enrolls from it.
- `agent-identity-mock` and `agent-identity-conformance` form an
  independent conformance suite that the testsuite runs against the
  agent.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Lets the Windows and Linux test jobs both finish while the agent identity
conformance tests are expected to fail. To be reverted before the pull
request leaves draft status.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…outcome

From experiment 1 (RFC 9421, DPoP, and DPoP with authority-issued nonces,
built as probes against a shared proxy harness and judged blind), the
maintainer kept RFC 9421 and recorded the two decisions the evidence
settled, each with the alternative it excludes: request authentication
never covers the host or path, and the agent supplies each request's
freshness.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…d hit

Found by the blind judge of experiment 1 and approved by the maintainer:

- a verifier detects repeated parameters, members and labels on the raw
  field, since RFC 8941 parsers keep only the last occurrence;
- byte sequences in `Signature` and `Content-Digest` use canonical
  padded base64;
- a refused channel opening carries a `server-time` trailer, so the
  agent's single clock-skew retry works on the channel too.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
The rule-to-test mapping, the review-only claims and the known gaps
describe the conformance suite, not something the agent and an authority
agree on, and they drift as tests are renamed. The contract keeps what
conformance means; the mapping moves to the suite's COVERAGE.md.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
The contract's rules define conformance; the suite is independent
evidence derived from them, so defining conformance as passing the suite
inverted that. §6 still requires the shared vectors, and the suite's role
is stated in its own intent.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
The vectors are evidence for the §6 rules, not a rule both parties agree
to, so their role, the signer's pass condition and the independent
verifier move to the shared intent's Conformance section. The vector
file no longer claims a signer reproduces signature bytes, which
contradicted that condition.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
The contract no longer defines friendly names, so §5.2 drops its §3
reference, and the DVLS intent points at the shared intent's Enrollment
section, where the format is defined.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…adapter

The device-key file backend signs through RustCrypto's Signer and the httpsig adapter's tests implement it; Signer::sign panics on failure, so both crates keep calling try_sign.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
A hard link keeps the regular-file type, owner and ACL of the key it aliases, so the reparse-point and symlink checks let it through.
The file backend now refuses to open a key file whose link count is not one.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
The software provider persists a key only at finalization, so two creators of one name could both succeed and the later one replaced the earlier key.
Creation now takes a global named mutex for the key name, checks that the key does not exist, and holds the lock through finalization.
The wait is bounded at five seconds, like the file backend's lock.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
The DVLS intent keeps decisions and leaves exact routes, fields and codes
to DVLS's OpenAPI document. It adds the root's creation, custody,
expiry and failure rules, the channel offer as a hosting property,
renewal load shedding, the grace identity status, four management
permissions, and a suggested 90-day token expiry with an expiry warning.

The suite reaches each authority's management API through a module per
authority, and N/A covers tests that module declares unsupported. The
mock replicates DVLS's management API as it is. The channel client backs
off up to 1 h when the path cannot carry gRPC. Only the caller's account
and SYSTEM can block a Windows key's creation.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
An independent verifier found points the profile left open. Step 1 now
names one member in each of Signature-Input and Signature, parameter
types, no parameters on the Signature member, a covered component or the
Content-Digest member, the keyid and nonce formats, and canonical
base64. A malformed keyid fails there, so an agent bug never draws a
terminal identity_unknown. Unexpired follows RFC 5280 (now <= notAfter),
and uncovered headers are ignored.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Each byte string has one canonical base64url encoding; a padded,
standard-alphabet or non-zero-pad-bit keyid or nonce fails step 1 like
non-canonical base64 does.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
A future notBefore can only come from the authority's own clock, so
rejecting it would lock devices out over an authority-side fault.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Request bodies reject repeated JSON member names at any depth, every
base64 and base64url value is canonical, only the exact token string
authenticates, and a CSR key must be an uncompressed P-256 point so each
key has one SPKI SHA-256. A friendly name is trimmed again after
truncation, so it never ends in white space.

Issue: DGW-478

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
During pending-key recovery the expired pending key sits in `previous`
and the fresh key in `pending`, so a restart resumes recovery. A runner
checks in when it starts, including after a new enrollment, and the
channel's 1 h timer restarts when the URL changes. A rejected identity
keeps its keys until replaced or uninstalled, an unusable pending key is
defined, and the agent never judges certificate validity itself: the
server's clock decides.

Issue: DGW-478

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
The contract gains a transient `unavailable` code for 5xx answers, which
overload shedding and an unusable root key need. DVLS's management API
becomes part of its public API and follows that API's contract, with
settings readable under View and changeable under Manage.

Issue: DGW-478

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
When the authority's clock is ahead, notBefore lies in the agent's
future and a schedule anchored on it renews late by the skew. Anchoring
on the earlier of notBefore and the moment the agent stored or loaded
the certificate keeps renewal at two thirds of the authority's lifetime
without storing anything new.

Issue: DGW-478

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
The bag is unsigned routing data and the authority keeps only the
secret's hash, so an altered bag authenticates like the original and
no authority needs to store the full token.

Issue: DGW-478

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
The route spelling now follows DVLS's public API contract, so the intent
keeps only the naming decision: records are identities, and "device"
stays free for a later machine view.

Issue: DGW-478

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
An empty extensionRequest attribute violates RFC 5280's SIZE (1..MAX),
so a strict PKCS#10 parser at an authority rejects the request. The
server still ignores any attribute it receives.

Issue: DGW-478

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
RFC 2986 requires the attributes field, so "no attributes" means an
empty set encoded A0 00, not an omitted element.

Issue: DGW-478

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Common CSR builders emit an extensionRequest with an empty Extensions
list. The agent no longer sends one, but an authority ignores CSR
attributes, so it must not reject that form with a strict parser.

Issue: DGW-478

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
… recovery

When pending-key recovery has to start again, the newest expired pending
certificate is the one the server holds as pending or current, so it
replaces the older one in `previous` and still fits three slots.

Issue: DGW-478

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
The contract now names the authority's record an identity, so the mock
serves `identity_id`, `identity_revoked` and `identity_unknown` on the
wire, `/identities` and `activeIdentitiesOnOldRoot` in the admin API,
and `identity_id` throughout its control surface.

The mock refuses a non-loopback listen address because its control
surface is unauthenticated. Every refused or failed channel stream
carries a `server-time` trailer next to `error-code`. A repeated
`Authorization` header on enroll is `token_invalid`.

Supporting tests pin the body-limit and authentication order for each
agent-facing endpoint, and the oracle's rejection of non-canonical
base64 and repeated labels or members.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Issue: DGW-478
Point friendly-name evaluation and the rotation ticker at the sections
that specify them, and wrap comment lines at the 120-column limit.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Issue: DGW-478
The identity contract names the authority's record an identity, with
`identity_id`, `identity_revoked` and `identity_unknown` on the wire.
Rename the enroll response field, the terminal error codes, the
`identity.json` field and the rejection-marker code to match.
Nothing has shipped, so no migration or alias is kept.

Point the retry-delay test at the documents that now own the rule.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Issue: DGW-478
The identity module owns the identity service task, the `identity`
command and the `Identity` and `__debug__.identity` configuration, so
that every Agent Identity change in the agent binary happens under one
directory and its intent.

Move the `identity enroll` command into `identity::cli` and the
configuration types, their normalization and the debug-settings
warning into `identity::config`. Move the command dispatch out of
`main.rs` into a generic `cli.rs` that routes `identity` to the
identity module; every other command behaves as before. `config.rs`
keeps only the fields, each pointing to the identity module.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Issue: DGW-478
A missing current key used to delete the identity, so re-running the
same install command enrolled the token again as a new identity.
Record the identity as rejected with the code `key_lost` instead: the
same token stays a no-op, and recovery needs a different token, as
after a revocation.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Issue: DGW-478
When the configured device-key backend is unavailable, the identity
task used to log and retry forever. Agent Identity must instead fail
loudly and never fall back to another backend, because a different
backend would silently change where the device's keys live.

The store already probes the backend before it touches any pending
file, identity or directory. On device-key's distinguishable
`BackendUnavailable` error, the task now logs one error with the
backend name and error chain, raises the system event
`IDENTITY_KEY_BACKEND_UNAVAILABLE` (1100, a new Agent Identity range),
and returns, so the identity feature stays stopped for the rest of the
process lifetime while the other agent tasks keep running. Other
initialization errors keep retrying as before.

The agent gains a shared system event sink that mirrors the Gateway's:
the Event Log source `Devolutions Agent` on Windows, syslog with the
daemon facility and ident `devolutions-agent` on Unix, and a no-op sink
when neither can be opened. Unlike the Gateway, it emits in debug
builds too, so that a debug agent still reports the failure.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Issue: DGW-478
The contract defines config version 1 only, and a config with any other
version is a transient failure that keeps the stored config. The agent
used to accept any version.

Enroll and check-in responses now require `config.version` to be 1 and
otherwise fail as an invalid response, which the agent retries without
storing anything. Updating a stored identity's config refuses another
version and leaves the stored config as it was.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Issue: DGW-478
The agent now reports system events in debug builds too, and the
installer registers the agent executable as the Event Log message
file, so a debug executable without the catalog leaves its events
without a description. Embed the catalog in every Windows build.

Without a developer shell, look for mc.exe in the Windows SDK's
default installation directory, so that debug builds keep working
from a plain shell.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Issue: DGW-478
The catalog test needs a builder for every declared Agent event code,
so that each message keeps one insertion per field. Cover
`IDENTITY_KEY_BACKEND_UNAVAILABLE`.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Issue: DGW-478
Another account could create the global mutex a key's creation waited on and make every creation of that key time out.
The lock now lives in a private namespace whose boundary holds the caller's SID and whose DACL grants only the caller and SYSTEM, so no other account can create or open it.
Creators running as the same account still share the lock and serialize.
A namespace that disappears between a failed create and the open is created again, a bounded number of times.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Issue: DGW-478
Wait time-outs advance in system clock ticks, so a five-second wait can end slightly before five seconds of monotonic time have passed.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Issue: DGW-478
Rename the authority's records to identities on the wire, in the admin API and in test names.
Close the coverage gaps for malformed enroll authorization, error precedence, invalid Hello metadata, Z timestamps, refused-opening trailers, and non-canonical or repeated signature fields, with matching negative vectors.
Accept any strictly increasing config revision and any root profile, verify signer output under the vector keys, and report N/A only for platform-inapplicable tests.
Move the SKIP and N/A exit checks and the Agent Identity binary helpers into the testsuite's agent_identity module.

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

Verify issued chains with RFC 5280 path rules only and report an issuer algorithm the tester cannot verify as incomplete.
Let an overlapping page walk miss or repeat concurrent enrollments, and check exact-once listing in a separate walk.
Reject repeated labels and members within one Signature-Input, Signature or Content-Digest field, with matching negative vectors.

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

Identity status follows DVLS's terminology: an unexpired current or pending certificate makes an identity active, one within renewal grace makes it grace, and the identity filter accepts all four statuses.

Each management API route requires DVLS's View, Manage, Revoke or Rotate root permission and answers 403 without it.
Credentials derived from the admin token grant any subset of the permissions, so tests can exercise each denial.

The mock now says "management API", and the device-key creation lock cites the intent sentence it realises.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Issue: DGW-478
… step 1 rules

Each negative vector now fails only at the step it targets, so a verifier missing that one check returns a different code.
Cross-tag vectors keep the original Content-Digest header, so a verifier missing both the component and tag checks can rebuild the signed base.
connect_replayed_to_renew replays the connect's (keyid, nonce) in a correctly signed renew, which reaches the nonce store.
Over-covered vectors cover a content-type header the request carries instead of an absent @path.
The repeated-tag vector is signed over the member an RFC 8941 parser keeps, with the first occurrence before created, so only duplicate detection rejects it.
New vectors cover a padded keyid, a nonce with non-zero pad bits, a string created, parameters on the Signature member, a covered component and the Content-Digest member, the notAfter and grace-end boundaries, and an uncovered Content-Digest on confirm.
Renew grace is cited from the section 6 table and section 8.2.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Issue: DGW-478
A config whose version is not 1 is a transient failure, and the agent keeps its stored config.
One test delivers version 2 through check-in and requires the stored config to stay unchanged while the agent keeps checking in, then requires the next supported config to be stored.
Another delivers version 2 on enrollment and requires the pending file to stay and no identity to be stored until the version is supported.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Issue: DGW-478
The conformance suite needs the mock to deliver a config whose version is not 1, to check that the agent treats it as a transient failure and keeps its stored config.
The config_version fault replaces config.version in every delivered config: the enrollment and check-in responses, live ConfigUpdate pushes and the ConfigUpdate that follows Welcome.
Setting or clearing it bumps every identity's config revision and pushes ConfigUpdate, so the delivered config is always newer than the agent's stored one.

Issue: DGW-478
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
CONTRACT.md §6 keeps a certificate unexpired while now ≤ notAfter and never checks notBefore, because a future notBefore reflects only the authority's own clock.
The oracle's step 4, the identity statuses and the old-root rotation count now share that rule, and renewal grace stays now < notAfter + lifetime as the §6 table states.
A stream still closes at notAfter, as §7.4 requires, so the channel refuses an opening at that second with certificate_expired instead of opening a stream it must close at once.

Issue: DGW-478
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
CONTRACT.md §5.4 maps an unexpected server failure under the agent-facing API to 500 unavailable, a transient code with the usual error body.
The dispatcher rewrites every 5xx that a handler did not inject, including a panic it catches, to that response, and the fail_next_response fault accepts unavailable like the other §5.4 codes.

Issue: DGW-478
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
CONTRACT.md §1 rejects a request body that repeats a member name at any depth, and serde_json keeps only the last occurrence, so request bodies now go through a parser that fails on a repeated name.
The csr must already be canonical base64, and only a well-formed token with the canonical secret text reaches the token lookup, so only the exact token string authenticates (§2).
A CSR key must be an uncompressed P-256 point, so that each key has exactly one SPKI SHA-256 (§4).
A friendly name is trimmed again after truncation, so it never ends in Unicode White_Space.

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

CONTRACT.md §6 accepts a connect whose certificate is at now == notAfter, and §7.3 step 2 then sends Challenge.
The opening at that second is no longer refused; the stream receives its Challenge and then closes with certificate_expired, because §7.4 closes a stream no later than its certificate's notAfter.

Issue: DGW-478
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
CONTRACT.md §2 lets only the exact token string authenticate, so enrollment now compares the presented token with the one the mock issued for that secret, bag included, before the token lookup.
The management API follows the §1 protocol conventions as well, so its request bodies are parsed with the same rejection of a member name repeated at any depth.

Issue: DGW-478
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
CONTRACT.md §2 authenticates a token by its exact secret text and never checks the bag, which carries only routing data.
Enrollment no longer compares the bag with the one the mock issued; a token whose secret text differs, including a non-canonical encoding of the same bytes, stays token_invalid.

Issue: DGW-478
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
x509-cert's RequestBuilder always adds an extensionRequest attribute, which is an empty SEQUENCE when no extension is requested and breaks RFC 5280's SIZE (1..MAX).
Build the CertificationRequestInfo directly with an empty attribute set, sign its DER with the device key, and assemble the request.

Issue: DGW-478
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
RFC 2986 makes the attributes field mandatory, so an empty set must be encoded as A0 00 rather than omitted.
Assert that exact element in the encoded request and link the contract section from the doc comment.

Issue: DGW-478
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
The agent's CSR carries no attributes (contract §4), so `csr.valid` now has an empty attributes set.
Its key is new and stored with the vector, so the CSR can be regenerated.

The previous CSR, with an empty extensionRequest, stays as `valid_with_empty_extension_request`.
It is still accepted, because the server takes only the public key and the self-signature from a CSR.

Issue: DGW-478

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Issue: DGW-478
…rough its module

Management APIs are product-specific, so every route, field, paging rule, enum and status code of DVLS's management API now lives in `src/dvls.rs`, and tests use its typed operations and records.
A test that needs mock controls reports N/A on DVLS instead of SKIP, because that module declares it unsupported.

Identity statuses follow DVLS's terminology: an identity with an unexpired pending certificate is active, and one within renewal grace is in grace.
Each management API route group is checked with a credential lacking only its permission.
New checks cover repeated JSON members, non-canonical CSR encodings, compressed-point CSRs, exact token secret matching and friendly-name white-space trimming.
Expiry boundaries treat a certificate as unexpired at its notAfter, and Retry-After checks respect the agent's maximum retry delay.

Issue: DGW-478
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…bers tests use

A token, certificate or rotation record may omit the members the suite only compares, since DVLS's intent leaves the exact model to its OpenAPI document.
A token record discloses its token only if it holds the secret text, and the full-view check no longer depends on how a metadata selector combines with it.

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

A token record that names a member after the token discloses it as much as one that holds it as a value.

Issue: DGW-478
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Revocation, deletion and the certificate's notAfter close a stream that
still awaits Hello, ahead of the handshake failures, so authorities agree
on the outcome. A certificate retired during the handshake still
completes it, then gets Reconnect like any retired stream.

Issue: DGW-478

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