Skip to content

feat(server): validate auto-reconnect cookies - #1509

Merged
Marc-André Moreau (mamoreau-devolutions) merged 2 commits into
masterfrom
copilot/validate-rotate-server-auto-reconnect-co
Aug 2, 2026
Merged

feat(server): validate auto-reconnect cookies#1509
Marc-André Moreau (mamoreau-devolutions) merged 2 commits into
masterfrom
copilot/validate-rotate-server-auto-reconnect-co

Conversation

@mamoreau-devolutions

Copy link
Copy Markdown
Contributor

Summary

  • parse and carry ARC_CS_PRIVATE_PACKET data through the acceptor
  • validate returning Enhanced RDP Security cookies with HMAC-MD5 before reconnecting
  • rotate reconnect randoms per connection and hourly, with runtime cookie updates
  • restrict cookie authentication to TLS/Hybrid and document the behavior

Testing

  • cargo test -p ironrdp-pdu -p ironrdp-acceptor -p ironrdp-server
  • cargo clippy -p ironrdp-pdu -p ironrdp-acceptor -p ironrdp-server --all-targets -- -D warnings

Fixes: #1508

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings July 31, 2026 19:54
@github-actions github-actions Bot added dependencies Pull requests that update a dependency file A-core Area: Core tier A-community Area: Community 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

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Not ready to approve

Cookie secrets can leak through debug logging, runtime update failures invalidate usable cookies, and the arbitrary feature does not compile.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

This review doesn't count toward merge requirements. Sign up for the private preview to control whether Copilot approvals count.

Pull request overview

Adds server-side validation and rotation for RDP auto-reconnect cookies.

Changes:

  • Parses and propagates client reconnect packets.
  • Validates HMAC-MD5 verifiers and bypasses credential revalidation for valid reconnects.
  • Adds per-connection/hourly rotation and runtime updates.
File summaries
File Description
crates/ironrdp-server/src/server.rs Implements validation, rotation, and runtime updates.
crates/ironrdp-server/src/lib.rs Exports the update handle.
crates/ironrdp-server/src/builder.rs Documents lifecycle behavior.
crates/ironrdp-server/Cargo.toml Adds cryptographic and timing dependencies.
crates/ironrdp-pdu/src/rdp/client_info.rs Decodes client reconnect packets.
crates/ironrdp-acceptor/src/connection.rs Propagates reconnect data through acceptance.
Cargo.lock Locks new dependencies.
Review details
  • Files reviewed: 6/7 changed files
  • Comments generated: 5
  • Review effort level: Balanced

We're testing this review assessment. Please use 👍 or 👎 to tell us if it's correct.

Comment thread crates/ironrdp-pdu/src/rdp/client_info.rs
Comment on lines +523 to +524
/// Replace or clear the Server Auto-Reconnect Cookie.
SetAutoReconnectCookie(Option<rdp::session_info::ServerAutoReconnect>),

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 546f061. ServerEvent now implements a redacted Debug representation, so cookie and credential payloads cannot appear in event trace logs or SendError debug output.

Comment on lines +1291 to +1294
ServerEvent::SetAutoReconnectCookie(cookie) => {
self.set_auto_reconnect_cookie(cookie);
self.send_next_auto_reconnect_cookie(writer, io_channel_id, user_channel_id)
.await?;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 546f061. Runtime replacement now writes the Save Session Info PDU before committing the new cookie state, preserving the prior state if delivery fails.

Comment thread crates/ironrdp-server/src/server.rs Outdated
Comment on lines +506 to +507
/// `None` disables auto-reconnect and immediately invalidates the cookie
/// currently held by the server.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 546f061. AutoReconnectCookieHandle::set now explicitly documents that the update is queued and when a None update takes effect.

session_id: Option<u32>,
performance_flags: Option<PerformanceFlags>,
reconnect_cookie: Option<[u8; RECONNECT_COOKIE_LEN]>,
auto_reconnect: Option<ClientAutoReconnect>,

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 546f061. The builder now parses and stores the matching typed representation alongside the raw cookie, with regression coverage for valid builder input.

@glamberson

Copy link
Copy Markdown
Contributor

Marc-André Moreau (@mamoreau-devolutions) heads up on an overlap, plus one thing in the patch that is independent of it.

I have two open on this surface: #1496 (reissue the cookie mid-session) and #1501 (client half, session resume). #1501 adds ClientAutoReconnect to crates/ironrdp-pdu/src/rdp/client_info.rs, same name and same two fields as the one here, and #1496 adds the same ServerEvent::SetAutoReconnectCookie variant. The verifiers agree, so the halves interoperate: #1501 derives HMAC-MD5 keyed with random_bits over the 32 zero bytes, which is exactly what auto_reconnect_verifier_matches checks.

Tell me the merge order you want and I will rebase to fit, including closing either of mine if you would rather this PR carry the whole thing. The acceptor plumbing and the validation path here are yours and I am not looking to duplicate them.

Separately, one thing worth a look regardless of the ordering. In ExtendedClientOptionalInfo::decode:

optional_data.auto_reconnect = Some(ClientAutoReconnect::decode(&mut ReadCursor::new(&reconnect_cookie))?);

That ? propagates, so a client sending a 28-byte cookie with an unexpected cbLen or version now fails the entire Client Info PDU and loses the connection, where before those bytes were stored and ignored. That points the other way from #1458, #1489 and #1506. Treating a failed parse as "no auto-reconnect offered" instead of a decode error would keep the tolerance.

@mamoreau-devolutions

Copy link
Copy Markdown
Contributor Author

Given that this particular PR is not tested, just implemented based on the related issue, consider that your other PRs have precedence over mine. I will rebase and resolve conflicts accordingly

@glamberson

Copy link
Copy Markdown
Contributor

Marc-André Moreau (@mamoreau-devolutions) two more I'd found, with the fix instead of the question. Against 20736d56. Apply, adapt or ignore.

Rotation retires the old cookie too early. rotate_auto_reconnect_cookie commits the replacement the moment write_all returns, and from then on only the new one verifies. But write_all returning means the bytes hit the socket, not that the client read them, so a client that drops around the rotation is left holding a cookie the server no longer knows and can't reconnect at all. That's the ungraceful disconnect the feature exists for. The fix keeps the previous cookie valid until the next rotation, and treats an explicit set_auto_reconnect_cookie as a revocation so None still means none.

The validator bypass isn't documented. server.rs:1568 skips CredentialValidator when a cookie verifies. That's defensible and probably what you want, but it quietly changes what with_credential_validator promises, and Rui Carmo (@rcarmo)'s #1413 builds multi-user PAM handoff on that same validator. The fix documents it on both entry points rather than adding a knob.

--- a/crates/ironrdp-server/src/server.rs
+++ b/crates/ironrdp-server/src/server.rs
@@ struct RdpServer
     auto_reconnect_cookie: Option<rdp::session_info::ServerAutoReconnect>,
+    /// The cookie the current one replaced, still accepted on reconnect.
+    ///
+    /// Sending a replacement only proves the bytes reached the socket, not that
+    /// the client read them. Retiring the old cookie at that moment leaves a
+    /// client that dropped around the rotation holding one the server no longer
+    /// knows, unable to reconnect: precisely the ungraceful disconnect this
+    /// feature exists for. Honouring the previous cookie until the next rotation
+    /// closes that window, at the cost of two live cookies per session.
+    previous_auto_reconnect_cookie: Option<rdp::session_info::ServerAutoReconnect>,
     auto_reconnect_sent: bool,

@@ RdpServer::new
             auto_reconnect_cookie: None,
+            previous_auto_reconnect_cookie: None,
             auto_reconnect_sent: false,

@@ set_auto_reconnect_cookie
         self.auto_reconnect_cookie = cookie;
+        // An explicit set is a revocation, unlike a rotation: drop the previous
+        // cookie rather than carrying it over, so `None` really does invalidate
+        // what the server currently honours.
+        self.previous_auto_reconnect_cookie = None;
         self.auto_reconnect_sent = false;

@@ verify_auto_reconnect_cookie
-        self.auto_reconnect_cookie
-            .as_ref()
-            .is_some_and(|cookie| auto_reconnect_cookie_matches(cookie, reconnect))
+        // Either the current cookie or the one it replaced: see
+        // `previous_auto_reconnect_cookie` for why the old one stays valid.
+        [&self.auto_reconnect_cookie, &self.previous_auto_reconnect_cookie]
+            .into_iter()
+            .flatten()
+            .any(|cookie| auto_reconnect_cookie_matches(cookie, reconnect))

@@ send_next_auto_reconnect_cookie AND rotate_auto_reconnect_cookie (both sites)
-        self.auto_reconnect_cookie = Some(cookie);
+        self.previous_auto_reconnect_cookie = self.auto_reconnect_cookie.replace(cookie);
         self.auto_reconnect_sent = true;

Plus the same six-line doc note on with_credential_validator (builder.rs) and set_credential_validator (server.rs), stating that the validator isn't consulted on a valid auto-reconnect and that a deployment wanting it to gate every connection must leave the cookie unset.

cargo xtask check fmt/lints/tests green. One gap worth naming: there's no test for the two-cookie window. The server-side auto-reconnect path has none to extend, and standing up an RdpServer harness looked like scope you didn't ask for.

@clintcan

clintcan commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Tested this end-to-end against real mstsc — it works. This is in response to your answer to issue #1508

Rather than wire it into our downstream server (pinned well behind master, which would test the backport rather than this PR), I ran it on this branch via the repo's own crates/ironrdp/examples/server.rs, instrumented with .with_auto_reconnect_cookie(...) and an Enter-to-drop trigger (server.event_sender()ServerEvent::Quit), --sec hybrid + a self-signed cert, and drove a real Windows mstsc client.

The full auto-reconnect cycle (log):

auto-reconnect cookie provisioned … random_bits=[44, 11, 95, …]
Received connection peer=192.168.0.149:…        # mstsc connects
Sent Server Auto-Reconnect Cookie (Save Session Info PDU)
drop trigger — sending ServerEvent::Quit         # forced ungraceful drop
End of client loop: Ok(Disconnect)
Received connection peer=…                        # mstsc auto-reconnects
Client accepted
Auto-reconnect cookie validation accepted         # HMAC verified

Session resumed cleanly. So a real mstsc's ARC_CS_PRIVATE_PACKET verifier matches your HMAC-MD5(random_bits, [0;32]) construction, and the rotation timing is right (validated against the last-sent random, not a prematurely-rotated one).

Bonus: the reconnect happened to arrive from a different source IP than the initial connection (a ZeroTier address vs the LAN one) — so validation is correctly IP-independent and survived a network change (roaming client).

I also added a known-answer test that pins the verifier construction against an independent Python hmac/hashlib reference, so a future refactor can't silently break the HMAC. Feel free to fold it in:

#[cfg(test)]
mod auto_reconnect_tests {
    use ironrdp_pdu::rdp::client_info::ClientAutoReconnect;
    use ironrdp_pdu::rdp::session_info::ServerAutoReconnect;

    use super::{auto_reconnect_cookie_matches, auto_reconnect_verifier_matches};

    // Independently computed (Python hmac/hashlib):
    //   HMAC-MD5(key = 01..10, msg = 32 zero bytes) = 894025a9…261c
    const RANDOM_BITS: [u8; 16] = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16];
    const EXPECTED_VERIFIER: [u8; 16] = [
        0x89, 0x40, 0x25, 0xa9, 0x9d, 0x64, 0xab, 0x96, 0x64, 0x19, 0xec, 0x1e, 0xf1, 0x3c, 0x26, 0x1c,
    ];

    #[test]
    fn verifier_matches_reference_hmac_md5() {
        assert!(auto_reconnect_verifier_matches(&RANDOM_BITS, &EXPECTED_VERIFIER));
    }

    #[test]
    fn verifier_rejects_a_wrong_value() {
        let mut wrong = EXPECTED_VERIFIER;
        wrong[0] ^= 0xff;
        assert!(!auto_reconnect_verifier_matches(&RANDOM_BITS, &wrong));
    }

    #[test]
    fn cookie_match_requires_both_logon_id_and_verifier() {
        let cookie = ServerAutoReconnect { logon_id: 0x1234_5678, random_bits: RANDOM_BITS };
        let good = ClientAutoReconnect { logon_id: 0x1234_5678, security_verifier: EXPECTED_VERIFIER };
        assert!(auto_reconnect_cookie_matches(&cookie, &good));
        let wrong_id = ClientAutoReconnect { logon_id: 1, security_verifier: EXPECTED_VERIFIER };
        assert!(!auto_reconnect_cookie_matches(&cookie, &wrong_id));
    }
}

One minor API nit while I was here: with_auto_reconnect_cookie takes a ServerAutoReconnect, but ironrdp-server doesn't re-export it — a direct consumer of the crate has to reach ironrdp_pdu::rdp::session_info::ServerAutoReconnect. A pub use from the server crate would be friendlier.

Overall: LGTM and suitable — validated against a real client. Thanks for picking this up so fast!

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
@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
@mamoreau-devolutions

Copy link
Copy Markdown
Contributor Author

Greg Lamberson (@glamberson): addressed the tolerant client-cookie decoding, two-cookie rotation grace window, explicit revocation behavior, and validator documentation in 546f061. I am keeping #1496 and #1501 as the precedence path per the existing merge-order discussion.nn@clintcan: thank you for the mstsc validation. I also incorporated the independent HMAC-MD5 known-answer tests and re-exported ServerAutoReconnect in 546f061.

@mamoreau-devolutions
Marc-André Moreau (mamoreau-devolutions) merged commit 44f675e into master Aug 2, 2026
26 checks passed
@mamoreau-devolutions
Marc-André Moreau (mamoreau-devolutions) deleted the copilot/validate-rotate-server-auto-reconnect-co branch August 2, 2026 19:00
Greg Lamberson (glamberson) added a commit to lamco-admin/IronRDP that referenced this pull request Aug 2, 2026
The auto-reconnect cookie is credential material in both directions, and
both directions are logged today at debug level.

ServerAutoReconnect::random_bits keys the HMAC that proves, on reconnect,
that a client was the one last attached to the session (MS-RDPBCGR 5.5).
ClientAutoReconnect::security_verifier is that HMAC. Under Enhanced RDP
Security there is no client random, so 5.5 computes the verifier over 32
zero bytes: it is constant for a given cookie, and replaying it is enough
to resume the session. ExtendedClientOptionalInfo::reconnect_cookie is
the wire form of the same verifier.

All three derived Debug, and a client running at debug level writes both
halves to its log:

  ironrdp-session/src/x224/mod.rs:212  logs the whole SaveSessionInfoPdu
                                       it receives, so the random lands
                                       in the log
  ironrdp-connector/src/connection.rs:873  logs the whole ClientInfoPdu
                                       it sends, so the verifier lands
                                       in the log

That second line is why Credentials in this file already hand-writes
Debug to hide the password. The verifier travels in the same PDU and
deserves the same treatment.

This is not theoretical. A contributor validating Devolutions#1509 against mstsc
posted a server log reading

    auto-reconnect cookie provisioned ... random_bits=[44, 11, 95, ...]

which is a live reconnect credential in a public comment, produced by the
derived impl.

Hand-write Debug on the three types following the Credentials pattern.
logon_id is not secret and stays visible, so the output remains useful
for diagnosis. Redacting the parsed field alone would not be enough,
since reconnect_cookie carries the same bytes unparsed, and redacting the
leaf alone would not be enough either, so the tests assert the elision
survives nesting inside the Debug-derived parents.
Greg Lamberson (glamberson) added a commit to lamco-admin/IronRDP that referenced this pull request 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.
Marc-André Moreau (mamoreau-devolutions) added a commit that referenced this pull request Aug 2, 2026
## Summary
- parse and carry `ARC_CS_PRIVATE_PACKET` data through the acceptor
- validate returning Enhanced RDP Security cookies with HMAC-MD5 before
reconnecting
- rotate reconnect randoms per connection and hourly, with runtime
cookie updates
- restrict cookie authentication to TLS/Hybrid and document the behavior

## Testing
- `cargo test -p ironrdp-pdu -p ironrdp-acceptor -p ironrdp-server`
- `cargo clippy -p ironrdp-pdu -p ironrdp-acceptor -p ironrdp-server
--all-targets -- -D warnings`

Fixes: #1508

---------

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Greg Lamberson (glamberson) added a commit to lamco-admin/IronRDP that referenced this pull request 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.
Marc-André Moreau (mamoreau-devolutions) pushed a commit that referenced this pull request Aug 3, 2026
…uto-reconnect cookie (#1501)

> **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](#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.

[MS-RDPBCGR]:
https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpbcgr/e729948a-3f4e-4568-9aef-d355e30b5389
[RFC2104]: https://www.rfc-editor.org/rfc/rfc2104
Greg Lamberson (glamberson) added a commit to lamco-admin/IronRDP that referenced this pull request Aug 3, 2026
The auto-reconnect cookie is credential material in both directions, and
both directions are logged today at debug level.

ServerAutoReconnect::random_bits keys the HMAC that proves, on reconnect,
that a client was the one last attached to the session (MS-RDPBCGR 5.5).
ClientAutoReconnect::security_verifier is that HMAC. Under Enhanced RDP
Security there is no client random, so 5.5 computes the verifier over 32
zero bytes: it is constant for a given cookie, and replaying it is enough
to resume the session. ExtendedClientOptionalInfo::reconnect_cookie is
the wire form of the same verifier.

All three derived Debug, and a client running at debug level writes both
halves to its log:

  ironrdp-session/src/x224/mod.rs:212  logs the whole SaveSessionInfoPdu
                                       it receives, so the random lands
                                       in the log
  ironrdp-connector/src/connection.rs:873  logs the whole ClientInfoPdu
                                       it sends, so the verifier lands
                                       in the log

That second line is why Credentials in this file already hand-writes
Debug to hide the password. The verifier travels in the same PDU and
deserves the same treatment.

This is not theoretical. A contributor validating Devolutions#1509 against mstsc
posted a server log reading

    auto-reconnect cookie provisioned ... random_bits=[44, 11, 95, ...]

which is a live reconnect credential in a public comment, produced by the
derived impl.

Hand-write Debug on the three types following the Credentials pattern.
logon_id is not secret and stays visible, so the output remains useful
for diagnosis. Redacting the parsed field alone would not be enough,
since reconnect_cookie carries the same bytes unparsed, and redacting the
leaf alone would not be enough either, so the tests assert the elision
survives nesting inside the Debug-derived parents.
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-server Area: RDP server dependencies Pull requests that update a dependency file 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.

ironrdp-server: validate + rotate the Server Auto-Reconnect Cookie (follow-up to #1405)

4 participants