Parts 1 and 2 described the migration of my internal PKI, first on the Linux side and then on the Microsoft AD CS side. This third installment covers a side project that arose from a change in Windows behavior rather than a planned initiative: Since April 2026, any unsigned .rdp file has triggered an aggressive security warning—even for files I create myself, on my own network, to connect to my own machines.

> This isn’t an article about a clean, straightforward project. It’s the story of a project that worked right away on the server side, but gave me a hard time on the seemingly simplest part: reliably running a script in the background.

The trigger: CVE-2026-26151

In April 2026, Microsoft tightened the behavior of Remote Desktop Connection (mstsc) regarding .rdp files: a file not digitally signed by a trusted publisher now triggers a red banner "Unknown publishing server," which implicitly prompts a reflexive click—exactly the opposite of the intended effect—and which, in practice, blocks certain redirection methods (such as smart cards) until the warning is acknowledged.

“Warning: Unknown Remote Connection” alert on an unsigned .rdp file

On my personal infrastructure, where I generate .rdp files on the fly on each workstation rather than storing them in a central folder, this warning banner has become a daily nuisance. Rather than ignoring it out of habit (which, over time, makes me desensitized to the actual warning), I took advantage of the fact that my internal AD CS was already in place to sign these files automatically.

The Code Signing Certificate

First step: a new certificate template, dedicated to code signing (xxxxxxxxxxxx-RDP-File-Signing), with the Code Signing EKU (1.3.6.1.5.5.7.3.3), a modern KSP (RSA 3072, SHA-256), and a subject based on the DNS name of the machine holding the key.

General tab of the xxxxxxxxxxx-RDP-File-Signing template

Extensions: Code Signing Policy

Encryption: KSP, RSA 3072, SHA-256

The CONTEXT_E_ROLENOTFOUND Pitfall

The first real obstacle—and the most time-consuming one—was that any enrollment attempt (whether automatic enrollment or manual certreq -enroll -machine) consistently failed with CONTEXT_E_ROLENOTFOUND (0x8004e00c) — “the specified role has not been configured for the application.” No trace in the CAPI2 logs, no trace on the certificate authority side. A silent error with no obvious clues.

I checked the following in order: the template’s ACLs, the key storage provider, the EKU extensions, and the subject name (where I actually found a secondary error—the user’s primary name (UPN) was checked in the subject name format, which is inappropriate for a machine certificate; I corrected it while I was at it, but that didn’t explain the main error), the compatibility settings, and the key attestation. Everything was correct.

Subject Name tab—UPN incorrectly checked, corrected while I was at it but not the cause of the main error

The actual cause, discovered after combing through the documentation from start to finish: I had duplicated this template from the built-in Microsoft "Code Signing" template, which—at the Active Directory schema level—is a User-type template. This type marker is not displayed in any tab of certtmpl.msc: it is silently inherited from the source template at the time of duplication, regardless of the ACL permissions subsequently granted to a machine account. A template intended to issue a certificate for a machine account must be duplicated from a template that is already of the Computer type (for example, “Computer” or “Web Server”), never from a User template that has been modified afterward, no matter how correctly the other settings may be configured.

Once the template was recreated from a Computer template and then reconfigured identically (EKU, KSP, subject), the enrollment worked on the first try.

Along the way, before identifying the true cause, I also ran into a more common permissions error—which is worth documenting because it can waste time if mistaken for the actual problem:

“Insufficient access rights” when modifying the template—a permissions error distinct from CONTEXT_E_ROLENOTFOUND

Chosen architecture: the key remains centralized

On my infrastructure, .rdp files are created locally, on the fly, on each workstation—there’s no central folder to monitor. Two options were available for signing these files:

  • Option B (rejected): Duplicate the private signing key on each workstation to sign locally. Simple in theory, but this multiplies the attack surface by the number of workstations, and compromising a single workstation compromises the signing key for the entire infrastructure.
  • Option A (selected): Keep the private key centralized on the certificate authority (AD-CS) and have it signed remotely via PowerShell Remoting, on demand, whenever a local .rdp file needs to be signed.

Option A: Centralized key, on-demand signing via WinRM/JEA — vs. Option B: Key duplicated on each workstation

The "RdpSigning" JEA Endpoint

To avoid granting full administrative WinRM access to the AD-CS, I built a JEA (Just Enough Administration) endpoint: a restricted PowerShell session (RestrictedRemoteServer), running under a virtual account (RunAsVirtualAccount), which exposes only a single function (Invoke-RdpSign) and nothing else on the system.

Since my AD forest has two domains, access is scoped to the groups Domain1\Domain Admins and Domain2\Domain Admins—via JEA’s RoleDefinitions, which natively support multiple domains without requiring the creation of a cross-domain universal group (I prefer to avoid this kind of dependency when a native, domain-isolated solution exists).

Three pitfalls encountered during development:

  • Versioned module structure without a manifest. A Modules\RdpSigningJEA\1.0\... directory tree is invisible to module discovery by name if it does not contain a .psd1 manifest file. Fixed by flattening the module (removing the version subfolder).
  • Select-Object -First is not supported in a RestrictedRemoteServer session, even within a trusted, imported module function. Workaround: use standard array indexing.
  • $env:TEMP is not defined for a RunAsVirtualAccount virtual account (no user profile loaded). Fixed by using a hard-coded path (C:\ProgramData\RdpSigningJEA\Temp) rather than relying on a missing environment variable.

An end-to-end test from a client machine validated the endpoint: Invoke-Command -ConfigurationName RdpSigning call returned Success=True with the correct signature certificate thumbprint.

The Client-Side Watcher

On the client side, a PowerShell script based on FileSystemWatcher and Register-ObjectEvent monitors the Desktop, detects any .rdp files that are created or modified, calls the JEA endpoint, and writes back the signed file received in response.

Two pitfalls to note:

  • $using: is not valid in a -Action scriptblock of Register-ObjectEvent (this syntax is only valid in Invoke-Command or Start-Job). This was fixed by passing the necessary values via -MessageData, which are then retrieved using $Event.MessageData.
  • Race condition with mstsc. The Remote Desktop client sometimes keeps the .rdp file open for writing for a brief moment after it is created. With a fixed 800-ms delay before reading, the watcher would sometimes read the file too early, resulting in a corrupted file—an end-of-file property appearing after the signature block:

“This RDP file is corrupted” — a result of the race condition between mstsc and the watcher reading the file too early

Fixed by replacing the fixed delay with a wait loop that attempts an exclusive open of the file (FileShare::None) until mstsc has actually released it, before reading and signing it.

From .rdp creation to the “verified publisher” banner

Making the watcher persistent: the project that went wrong

This is the least glorious part of this article, and probably the most useful for anyone attempting the same thing: once validated, the watcher only ran in interactive PowerShell—without persistence. Close the window or restart the computer, and nothing is monitoring the desktop anymore.

The original intention was simple: a scheduled task deployed via GPO, under User Configuration → Preferences → Scheduled Tasks, triggered at logon, run in the context of the logged-in user (%LogonUser%, required for Kerberos authentication with the JEA endpoint), targeted via security group filtering (Item-Level Targeting) to Domain Admins rather than by OU—since admin accounts are not isolated in a dedicated OU.

GPP Scheduled Task Configuration — General Trigger: at logon Action: Launch the watcher via hidden PowerShell Settings: Do not start a new instance if the task is already running Targeting via the Domain Admins security group

It never worked properly. The task consistently appeared as denied in the detailed gpresult report, despite what appeared to be correct targeting (valid group and SID, account was indeed a member of the Domain Admins group). Moving the preference item to Computer Configuration caused it to “trickle down”—but at the cost of breaking the very logic of the setup (the %LogonUser% context is unreliable in computer-side processing, where no user is necessarily logged in at the time of processing).

By attempting to use a loopback process (Fusion mode) to apply the user settings from a GPO linked to the workstation’s OU regardless of the admin account’s actual OU, and then switching to a standard logon script to replace the GPP scheduled task, I ended up causing the most concrete issue of the project: a synchronous login script (by default), which erroneously pointed directly to the watcher itself (which, by design, never terminates) rather than to a detached launcher, blocked a workstation’s login process, leaving it stuck on a solid black screen — with Windows waiting indefinitely for a script that never finishes.

The lesson was clear enough to warrant changing our approach rather than continuing to pile on workarounds. On a limited scope of just a few admin accounts—not the ~27 machines in the fleet—the cost-benefit ratio of a GPO (group filtering, loopback, application timing, risk of session lockup) was disproportionate. The solution chosen: a local scheduled task, created once per machine and per admin account with a simple PowerShell command (Register-ScheduledTask), without using a GPO. Less elegant on paper than a centralized deployment, but reliable, quick to diagnose, and with no risk of locking up a session across the entire domain.

Testing and Validation

Once the watcher was operational (regardless of the startup mechanism), the entire chain was validated from start to finish: an .rdp file created on the desktop is detected, signed via the JEA endpoint, and rewritten with its signature. On the GPO side, the SHA1 thumbprint of the signing certificate was declared as a trusted publisher (Specify the SHA1 thumbprints of certificates representing trusted .rdp file publishers), first tested locally via the registry (HKLM\SOFTWARE\Policies\Microsoft\Windows NT\Terminal Services, value CertificateThumbprints) before any actual GPO deployment.

Result: The banner changes from red (“Unknown publishing server”) to yellow, indicating a recognized and named publisher.

Recognized publisher — yellow banner after signing and declaring the thumbprint as approved

The Assumed Limitation

One issue kept me going in circles longer than necessary: the yellow banner “Verify the publisher of this connection” reappears with every connection, even for a publisher declared to be trusted. I tested several possibilities before finding the correct explanation: resetting the redirection properties (smart card, clipboard) had no effect, the hypothesis that mstsc was rewriting or deleting the signature upon exit—disproved by a byte-by-byte comparison of the file before and after—and adding the certificate to the Trusted Publishers store—which also had no effect.

The correct answer, found in Microsoft’s official announcement reported by the tech press: this banner reappears with every connection by design, even for a declared trusted publisher. It is a deliberate anti-phishing security measure, not a configuration flaw. Only a completely different message—an educational one displayed just once per account (“What is an RDP file?”)—is actually saved; the publisher verification banner itself is never saved.

The actual goal of this project—to eliminate the red "unknown publisher" banner, which encourages a reflexive click and breaks the smart card redirection—has indeed been achieved. The yellow banner that remains is the maximum achievable in this new security model; it should be documented as is rather than attempting to make it disappear.

Key Takeaways

  • A template type silently inherited (User vs. Computer) during duplication can cause a completely silent registration error—with no trace in the logs—making it particularly difficult to diagnose.
  • Centralizing a signature key rather than distributing it across each workstation is worth the cost of an additional round-trip over the network—especially with JEA, which allows you to expose a specific action without granting full administrative access.
  • A login script that never terminates must never be called directly in synchronous mode—always go through a detached launcher that returns control immediately.
  • When faced with a stubborn GPO mechanism (ILT denied, erratic loopback, blocking script), it’s sometimes better to acknowledge that a simpler, more localized solution is more than sufficient for the actual scope, rather than stubbornly insisting on a centralized architecture out of principle.
  • Security behavior that appears to be a bug after several tests that seem to disprove it is sometimes an intentional design choice by the vendor—check the official documentation before continuing to search for a workaround that doesn’t exist.

Sources and References