Skip to content

feat(pdu,session,connector,server)!: support session resume via the auto-reconnect cookie - #1501

Merged
Marc-André Moreau (mamoreau-devolutions) merged 1 commit into
Devolutions:masterfrom
lamco-admin:feat/client-auto-reconnect-cookie
Aug 3, 2026
Merged

feat(pdu,session,connector,server)!: support session resume via the auto-reconnect cookie#1501
Marc-André Moreau (mamoreau-devolutions) merged 1 commit into
Devolutions:masterfrom
lamco-admin:feat/client-auto-reconnect-cookie

Conversation

@glamberson

@glamberson Greg Lamberson (glamberson) commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Rebased onto post-#1522 master. #1509 landed the server half of #1508 while this was open, including the ClientAutoReconnect structure. This PR no longer declares it; it extends it, and picks up the parts #1509 did not build.

What

The client half of automatic reconnection. The session layer surfaces the Server Auto-Reconnect Cookie, ironrdp-pdu derives and verifies the client's response to it, and the connector sends that response when resuming a session.

Why

A client whose connection drops ungracefully can reattach to its session instead of making the user log on again, provided it returns the cookie the server issued during logon (MS-RDPBCGR 1.3.1.5).

#1509 built the server side of that: it validates a returning ARC_CS_PRIVATE_PACKET and rotates the random. Nothing answers it. ironrdp-session decodes the cookie and drops it, ironrdp-connector has no way to send one back, and TODO(#271) still sits in ironrdp-client. So ironrdp-client cannot resume a session against ironrdp-server, and the validation #1509 added has no in-tree counterpart to exercise it.

The wire encoding was already there. ExtendedClientOptionalInfo carries, encodes and decodes a 28-byte autoReconnectCookie and its builder already had a reconnect_cookie step; ServerAutoReconnect already decoded; #1509 added ClientAutoReconnect and its decode. Nothing connected them.

The three parts

Receive. SaveSessionInfo now also surfaces the cookie, as ProcessorOutput::AutoReconnectCookie and ActiveStageOutput::AutoReconnectCookie. #1522 added a SaveSessionInfo { logon_complete } output on that same handler; the two coexist rather than compete, since both are read off one PDU and neither supersedes the other. The handler emits the logon notification unconditionally and appends the cookie when one is present, and a test pins that surfacing the cookie does not suppress the notification. #1509's server replaces the cookie whenever a client connects and again hourly (MS-RDPBCGR 3.3.6.2), so this can arrive more than once in a session and the consumer keeps the most recent.

Derive. ClientAutoReconnect::from_server_cookie implements MS-RDPBCGR 5.5:

The auto-reconnect random is used to key the HMAC function (RFC2104), which uses MD5 as the iterative hash function. The security verifier is derived by applying the HMAC to the client random received in Step 3.

SecurityVerifier = HMAC(AutoReconnectRandom, ClientRandom)

When Enhanced RDP Security is in effect the client random value is not generated (section 5.3.2). In this case, for the purpose of generating the security verifier, the client random is assumed to be an array of 32 zero bytes.

IronRDP implements no Standard RDP Security path (there is no Security Exchange PDU), so the zero-client-random case is the only one that arises. As 5.5 notes, that makes the verifier constant for a given cookie, so it proves possession of the cookie and nothing more; session security comes from the outer TLS/CredSSP handshake.

clintcan independently confirmed this construction against real mstsc while validating #1509 (comment): a Windows client's ARC_CS_PRIVATE_PACKET verifies against HMAC-MD5(random_bits, [0u8; 32]). That is the same derivation implemented here, so the two halves interoperate with Microsoft's client and not only with each other.

Send. ClientConnector::with_auto_reconnect_cookie takes the cookie last received and makes the connector put the derived Client Auto-Reconnect Packet (MS-RDPBCGR 2.2.4.3) in the Client Info PDU. Absent, that PDU is byte-for-byte what it was.

Unlike the server packet, this structure has no enclosing logon-info field header, so it encodes to exactly the 28 bytes the cookie field expects. to_bytes writes that layout directly rather than going through Encode, so filling a fixed-size field has no error path a caller must handle; a test pins the two to agree.

One derivation, not two

Putting from_server_cookie in ironrdp-pdu would leave the workspace with two implementations of 5.5, since #1509 added a private HMAC to ironrdp-server. So ClientAutoReconnect also gains verify, and the server routes through it.

verify keeps the constant-time comparison the server had. The verifier is the whole credential, so a comparison returning early on the first differing byte would let a peer recover it a byte at a time from the timing; the session identifier is not secret and is compared normally. ironrdp-server keeps the policy around the check, which cookies are live and whether the security protocol permits auto-reconnect, and drops its hmac and md-5 dependencies. hmac moves to ironrdp-pdu as default-features = false; the crate's full feature powerset still checks clean, including --no-default-features.

I would rather not have reached into ironrdp-server in a pdu,session,connector change, but the alternative was shipping the duplicate and filing a follow-up to remove it, which is a worse trade for reviewer time.

Tests that were not running

That move also rehomes the known-answer tests clintcan contributed on #1509. They went in as an inline #[cfg(test)] module in crates/ironrdp-server/src/server.rs, and that crate sets [lib] test = false, so they have never executed in CI. They now live in ironrdp-testsuite-core against the public API, where CI runs them: his HMAC-MD5 reference vector is kept as a second vector alongside a differently-keyed one, plus the cases for a tampered verifier and a mismatched logon ID.

Worth flagging separately: ironrdp-server is not alone. ironrdp-agent, ironrdp-session and ironrdp-web also set [lib] test = false and between them carry 16 files of inline #[cfg(test)] modules that CI never runs. That is out of scope here, but I am happy to open an issue if it would be useful.

Breaking changes

ActiveStageOutput and x224::ProcessorOutput gain a variant, and ClientConnector gains a public field, so exhaustive matches and struct literals need updating.

Confirmed with cargo-semver-checks against the merge-base: those three are the only findings this branch introduces. The others it reports on master today (ShareDataPdu::Compressed and the ShareDataCtx fields from #1518, ProcessorBuilder.bulk_decompressor from #1518, ServerEvent::SetAutoReconnectCookie from #1509) are present on master unchanged. The ironrdp-pdu additions are additive.

Scope

This is the library half. ironrdp-client, ironrdp-web and the FFI bindings gain an arm for the new output but none of them reconnect automatically yet; that is the remaining part of #271, and the existing TODO(#271) in ironrdp-client marks where it goes.

I kept receive, derive and send together deliberately. Split up, none of them is usable on its own: without the receive half there is no way to obtain a cookie, and without the send half there is nothing to do with one.

Tests

Thirteen, all in ironrdp-testsuite-core.

On the packet and the derivation: the SecurityVerifier matches two independently computed HMAC-MD5 vectors of 32 zero bytes under different keys, so the tests pin the derivation rather than restating the code; the logon ID carries over from the server cookie; the encoding matches the 2.2.4.3 field layout byte for byte with cbLen fixed at 0x1C; to_bytes agrees with Encode; it round-trips; and it rejects both a wrong packet length and an unknown version.

On verification: a derived answer is accepted, a single flipped byte in the verifier is rejected, a correct verifier under a different logon ID is rejected, and an answer derived from a different random is rejected.

On the surfacing path: a Save Session Info PDU framed the way a server sends it, through the real x224 processor, yields an AutoReconnectCookie carrying the right logon ID and random bits, alongside #1522's logon notification rather than in place of it; and one without a cookie surfaces no cookie.

Verification

cargo xtask check fmt/lints/tests/typos/locks all pass on 1.94.1, including a fuzz/ build before the lock check.

Note

#1496 also touches the ClientAutoReconnect declaration. Whichever of the two lands second needs a one-line rebase on the derive attribute; happy to take that in either order.

@github-actions github-actions Bot added dependencies Pull requests that update a dependency file A-web-client Area: Web client (ironrdp-web, iron-remote-gui, iron-svelte-client) A-core Area: Core tier A-extra Area: Extra tier A-internal Area: Internal tier A-server Area: RDP server rust Pull requests that update Rust code size/L Size: 400-800 lines of code labels Jul 31, 2026
@glamberson
Greg Lamberson (glamberson) force-pushed the feat/client-auto-reconnect-cookie branch from 5427859 to c87e7cc Compare July 31, 2026 16:57
@github-actions github-actions Bot added size/L Size: 400-800 lines of code and removed size/L Size: 400-800 lines of code labels Jul 31, 2026
@github-actions github-actions Bot added size/L Size: 400-800 lines of code and removed size/L Size: 400-800 lines of code labels Jul 31, 2026
@glamberson
Greg Lamberson (glamberson) force-pushed the feat/client-auto-reconnect-cookie branch from c87e7cc to 76c35a9 Compare July 31, 2026 19:08
@github-actions github-actions Bot added size/L Size: 400-800 lines of code and removed size/L Size: 400-800 lines of code labels Jul 31, 2026
@glamberson
Greg Lamberson (glamberson) force-pushed the feat/client-auto-reconnect-cookie branch from 76c35a9 to 0f97db3 Compare July 31, 2026 23:18
@github-actions github-actions Bot added fuzzing Crashs and other bugs found by fuzzing size/L Size: 400-800 lines of code and removed size/L Size: 400-800 lines of code labels Jul 31, 2026
@glamberson
Greg Lamberson (glamberson) force-pushed the feat/client-auto-reconnect-cookie branch from 0f97db3 to 62b2abb Compare August 2, 2026 23:54
@glamberson Greg Lamberson (glamberson) changed the title feat(pdu,session,connector)!: support session resume via the auto-reconnect cookie feat(pdu,session,connector,server)!: support session resume via the auto-reconnect cookie Aug 2, 2026
@github-actions github-actions Bot added A-community Area: Community tier size/L Size: 400-800 lines of code and removed size/L Size: 400-800 lines of code labels Aug 2, 2026
…onnect cookie

A client that loses its connection ungracefully can reattach to the session
instead of asking the user to log on again, provided it returns the cookie the
server issued ([MS-RDPBCGR] 1.3.1.5). Devolutions#1509 built the server half of that: it
validates a returning ARC_CS_PRIVATE_PACKET and rotates the random. The client
half is still missing. The session layer decodes the cookie and drops it, and
the connector has no way to send one back, so ironrdp-client cannot resume a
session against ironrdp-server or against mstsc's peer.

The wire encoding was already in place. ExtendedClientOptionalInfo carries,
encodes and decodes a 28-byte autoReconnectCookie and its builder already had a
reconnect_cookie step; ServerAutoReconnect already decoded, and Devolutions#1509 added the
ClientAutoReconnect structure and its decode. Nothing connected them.

Receive: SaveSessionInfo now surfaces the cookie as
ProcessorOutput::AutoReconnectCookie and ActiveStageOutput::AutoReconnectCookie
rather than logging it and returning nothing. The server replaces the cookie on
every connect and again hourly ([MS-RDPBCGR] 3.3.6.2), so this can arrive more
than once per session and the consumer keeps the most recent.

Derive: ClientAutoReconnect gains from_server_cookie, implementing [MS-RDPBCGR]
5.5, SecurityVerifier = HMAC(AutoReconnectRandom, ClientRandom), keyed by the
server's 16 random bytes with MD5 as the hash. Enhanced RDP Security generates no
client random and 5.5 substitutes 32 zero bytes; IronRDP has no Standard RDP
Security path, so that is the only case. This moves hmac into ironrdp-pdu,
alongside the md-5 it already used.

Send: ClientConnector::with_auto_reconnect_cookie takes the cookie last received
and makes the connector put the derived Client Auto-Reconnect Packet
([MS-RDPBCGR] 2.2.4.3) in the Client Info PDU. Absent, that PDU is byte-for-byte
what it was.

Unlike the server packet this structure has no enclosing logon-info field header,
so it encodes to exactly the 28 bytes the cookie field expects. to_bytes writes
that layout directly rather than through Encode, so filling a fixed-size field
has no error path; a test pins the two to agree.

One derivation, not two. Putting from_server_cookie in ironrdp-pdu would
otherwise duplicate the private HMAC in ironrdp-server that Devolutions#1509 added, so this
also gives ClientAutoReconnect a verify method and routes the server through it.
verify keeps the constant-time comparison the server had: the verifier is the
whole credential, so an early-exit compare would leak it a byte at a time. The
server keeps only the policy around it, which cookies are live and whether the
security protocol permits auto-reconnect, and drops its hmac and md-5
dependencies.

That move also rehomes the known-answer tests @clintcan contributed on Devolutions#1509.
They were written as an inline #[cfg(test)] module in ironrdp-server, which sets
[lib] test = false, so they never ran. They now live in ironrdp-testsuite-core
against the public API, where CI executes them, together with his HMAC-MD5
reference vector and the cases covering a tampered verifier and a mismatched
logon ID.

ironrdp-client, ironrdp-web and the FFI bindings gain an arm for the new output.
None of them reconnect automatically yet, which is the remaining part of Devolutions#271.

BREAKING CHANGE: ActiveStageOutput and x224::ProcessorOutput gain a variant and
ClientConnector gains a public field, so exhaustive matches and struct literals
need updating. Confirmed with cargo-semver-checks; the ironrdp-pdu half is
additive.
@glamberson
Greg Lamberson (glamberson) force-pushed the feat/client-auto-reconnect-cookie branch from 62b2abb to e3a67ca Compare August 2, 2026 23:59
@github-actions github-actions Bot added size/L Size: 400-800 lines of code and removed size/L Size: 400-800 lines of code labels Aug 2, 2026
@glamberson

Copy link
Copy Markdown
Contributor Author

Marc-André Moreau (@mamoreau-devolutions) ready here i think.

@mamoreau-devolutions
Marc-André Moreau (mamoreau-devolutions) merged commit 74b3365 into Devolutions:master Aug 3, 2026
26 checks passed
@glamberson
Greg Lamberson (glamberson) deleted the feat/client-auto-reconnect-cookie branch August 3, 2026 00:48
GlassOnTin added a commit to GlassOnTin/IronRDP that referenced this pull request Aug 3, 2026
…or EGFX clients

Adds an opt-in `Config::support_dyn_vc_gfx_protocol` (default `false`). When
set, `SUPPORT_DYN_VC_GFX_PROTOCOL` is OR'd into the client's early capability
flags, so the server may negotiate `Microsoft::Windows::RDS::Graphics` for
surface-based graphics instead of the legacy slow-path bitmap protocol.

BREAKING CHANGE: `Config` is not `#[non_exhaustive]`, so the new field breaks
downstream struct literals. Same shape as Devolutions#1501, which added
`auto_reconnect_cookie` to this struct.

The default is `false` on purpose. A client that advertises the bit without
wiring an EGFX-capable `DvcClientProcessor` for that channel gets modern
Windows servers to stop sending legacy bitmap updates altogether and route
everything over EGFX, leaving the desktop blank — so the doc comment spells
out the three steps to enable it properly.

Coverage:
  - create_gcc_blocks unit tests (inline mod at file end, per Devolutions#1527) assert
    the bit is clear by default, set when enabled, and that the opt-in's
    symmetric difference against the default flag set is exactly that one bit
    — so it cannot quietly disturb the others. Verified to fail: gating on
    `if false` reddens two of the three.
  - testsuite-extra e2e runs a full client<->server connect with the bit
    advertised. Scope stated in the test: it asserts negotiation completes,
    not that the server acted on it, since ironrdp-server does not surface
    the client's early capability flags.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

A-community Area: Community tier A-core Area: Core tier A-extra Area: Extra tier A-internal Area: Internal tier A-server Area: RDP server A-web-client Area: Web client (ironrdp-web, iron-remote-gui, iron-svelte-client) dependencies Pull requests that update a dependency file fuzzing Crashs and other bugs found by fuzzing rust Pull requests that update Rust code size/L Size: 400-800 lines of code

Development

Successfully merging this pull request may close these issues.

2 participants