RDP Guard : Step-Aside on Lapse / Deactivation
Design: Step-Aside on Lapse / Deactivation (RDP Guard)#
Status: IMPLEMENTED 2026-07-02 (v1.0.42). See "Implementation map" at the bottom.
Original status: DRAFT for review.
Date: 2026-07-02
Owner decision: jasmin โ after Block's paid period ends, OR after a server is
Deactivated, the Gatekeeper must STOP requiring OTP and let the user sign in with
plain Windows username + password. Disable (kill-switch) is UNCHANGED.
1. Problem & goal#
Today the agent always enforces OTP regardless of license/seat state; only the
kill-switch (disabled) denies. Customers who Block (cancel) or Deactivate a server
expect the guard to "step aside" so they aren't stuck entering OTPs for a server they
no longer pay for / no longer manage with the guard.Goal: in the "step-aside" states, the login gate lets the user in with the normal
Windows credential (no OTP), on BOTH console and RDP, WITHOUT locking anyone out.Explicitly accepted trade-off (owner sign-off): this removes MFA from a server on
non-payment / seat-free. Previously rejected as a security/revenue regression
(Gatekeeper.cs:150-157). This design implements the owner's reversal of that stance.
2. The three portal actions (must stay distinct)#
| Action | Server call | Sets | Post-change login (TARGET) |
|---|
| Block (cancel at period end) | CrudLicense(flag=2) โ status='activeuntilperiodend', cancelatperiodend=true | period-end | Until currentPeriodEnd: OTP as normal. After: step-aside (password only) |
| Deactivate (free seat) | DeactivateInstall โ activation Removed/deactivated | seat freed | Step-aside immediately (password only) |
| Disable (kill-switch) | SetServerDisabled โ disabled=true | kill-switch | UNCHANGED โ hard lockout "sign-in blocked" |
Reference: Authyo.BusinessTire/GatekeeperLicense.cs:36,258;
Authyo.UI/Controllers/GatekeeperController.cs:350,551;
Database/Gatekeeper/01_gatekeeper_license_schema.sql:130-155.
3. State definitions (how the agent detects "step-aside")#
The heartbeat already returns everything needed โ NO server change required
(AuthyoRdpGuardController.cs:172-191, LicenseModels.cs:72-84):licenseStatus โ {active, activeuntilperiodend, suspended, lapsed, cancelled}
activationStatus โ {active, deactivated, โฆ} (portal "Removed" โ deactivated)
currentPeriodEnd (timestamp)
disabled (kill-switch โ untouched by this design)
The agent already caches these on each authoritative heartbeat
(LicenseManager.cs:198-208): LastLicenseStatus, LastPeriodEndUnix, and it even
sets Activated=false when activationStatus=="deactivated".Define IsStepAside(license, now) = true when NOT disabled AND any of:1.
activationStatus == "deactivated" (Deactivated / seat freed), OR
2.
licenseStatus โ {lapsed, suspended, cancelled} (period already ended), OR
3.
licenseStatus == "activeuntilperiodend" AND LastPeriodEndUnix > 0 AND now > LastPeriodEndUnix
(Blocked and the paid period has now elapsed โ belt-and-suspenders in case the
server hasn't yet re-labelled the row from activeuntilperiodend โ lapsed).
disabled ALWAYS wins โ kill-switch still hard-denies (checked first, unchanged).Open question O1: should suspended (an involuntary billing failure, e.g. card
declined mid-period) step aside, or only voluntary Block/Deactivate? Voluntary =
{deactivated, lapsed-after-Block, cancelled}. Recommend treating suspended the
same as lapsed (step aside) for simplicity, but flag for owner.
4. Two code sites must change together (or it's a lockout)#
The gate and the credential-provider FILTER live in different processes and BOTH
must agree, or RDP breaks:4a. The gate (Gatekeeper.cs) โ return Allow instead of enforce-OTP#
Gatekeeper.cs:150-171 currently only logs the lapse and falls through to OTP.
Change: when IsStepAside, return BeginResult.Allowed("license stepped aside โ no OTP")
BEFORE the contact-pick / OTP-send path. Kill-switch check at :98-102 stays first
and unchanged.4b. The RDP filter (OTPCredentialProviderFilter.cpp) โ un-hide the password tile#
THIS is the lockout-critical half. Today :132-145 hides ALL non-Authyo tiles on
RDP unconditionally, and UpdateRemoteCredential:150-169 returns E_NOTIMPL. If 4a
returns Allow but the filter still hides the password tile, an RDP user sees NO usable
tile โ total lockout โ worse than today.Change: when IsStepAside, make the filter behave like it already does on console
(pass-through). Concretely:In Filter(...): add an IsStepAside() check that early-returns S_OK BEFORE the
suppress loop (same shape as the existing if (!IsRemoteSession()) return S_OK; at
:133). The password/PIN/picture tiles reappear.
In UpdateRemoteCredential(...): when IsStepAside, return the default/inbox
behavior instead of E_NOTIMPL so mstsc-supplied passwords complete (needed for a
smooth RDP password login). Verify the exact non-E_NOTIMPL return with a Server
2022 RDP test โ this is the riskiest line.
4c. Console (Gatekeeper.cs / SetUsageScenario) โ already safe#
Console already shows the password tile alongside ours (:96-101). Step-aside on
console = the gate returns Allow and the user picks the password tile. No filter change
needed for console. (If our tile also shouldn't appear at all in step-aside, optionally
hide it via SetUsageScenario โ nice-to-have, not required.)
5. The hard part: the filter can't call the gate#
The filter runs inside LogonUI, a different process, and today reads only
GkConfigPresent + IsRemoteSession โ it never touches license state. To decide
IsStepAside, the filter needs the cached license state.Options (pick one โ Open question O2):(A) Read the cached config file directly in the filter. The agent already writes
LastLicenseStatus, LastPeriodEndUnix, Activated, Disabled to config via
ConfigManager.Save on every heartbeat (LicenseManager.cs:227). The filter (C++)
reads the same fields the tile/bridge already read. Recommended โ reuses the
existing cache, no new IPC, and the kill-switch disabled flag is ALREADY consumed
at logon from this same cache, so the plumbing exists.
(B) A small shared "step-aside" sentinel file the agent writes on heartbeat. Simpler
to read but adds a new artifact + a staleness question.
(C) Have the filter call the bridge. Heavier; the filter is meant to be minimal.
Recommend (A). Confirm the C++ side already has a config reader it can extend
(the tile/bridge do โ GkConfigPresent proves config is reachable from this module).
6. Staleness / offline behavior (must be deliberate)#
Step-aside is decided from the CACHED license state (no network in the logon hot path).
Consequences:After Block, the server flips to lapsed at period end, but the AGENT only learns it
on its next heartbeat (throttled ~12h on Windows). So step-aside may lag the actual
period end by up to one heartbeat interval. Acceptable (fail-SECURE: keeps OTP a bit
longer, never opens early).
If the server is unreachable forever after a Deactivate, the cached deactivated
persists โ stays stepped-aside. Fine.
Interaction with serverAuthoritative/blockOnAuthyoUnreachable: step-aside is a
RELAXATION (fewer restrictions), so it should be evaluated only from a FRESH
authoritative cache when serverAuthoritative is on, mirroring how exempt/trust are
gated (Gatekeeper.cs:146-202). i.e. a stale cache must NOT be able to CLAIM
step-aside and drop MFA. Open question O3: confirm step-aside requires the same
policy-freshness gate as exempt/trust when serverAuthoritative=true.
7. Linux parity (pam_authyo)#
Mirror in pam_authyo.c:932-958: when IsStepAside (same state rules, from cached
last_license_status + activation state + period end), return PAM_IGNORE (fall
through to the next PAM module = normal password auth) instead of running the OTP flow.
The heartbeat daemon already caches license status (main.c:325). No SSH "hide tile"
problem exists on Linux โ PAM_IGNORE cleanly hands off to password auth. Kill-switch
(license_disabled) stays a hard PAM_AUTH_ERR at :924, unchanged.
8. UX / audit#
Log a clear line at the gate: GATE_STEPASIDE reason=deactivated|lapsed|periodend user=โฆ ip=โฆ
so the audit trail shows WHY OTP was skipped (important given it's an MFA-off event).
Optional: a one-time info banner on the tile in step-aside ("This server's Authyo
license has ended โ signing in with your Windows password"). Nice-to-have.
Portal copy: the tooltips already say "keeps enforcing OTP" for Deactivate โ those
MUST be updated to match the new behavior, or the portal will lie. (Doc/UI task.)
9. Test matrix (runtime, Server 2022 โ the E_NOTIMPL change demands real RDP tests)#
| State | Console | RDP | Expected |
|---|
| active | tile + password | OTP only | OTP required |
| Blocked, before period end | tile + password | OTP only | OTP required |
| Blocked, AFTER period end (lapsed) | password works | password tile visible + login works | step-aside, no OTP |
| Deactivated (Removed) | password works | password tile visible + login works | step-aside, no OTP |
| Disabled (kill-switch) | Authyo denies (console password still there โ see known gap) | hard lockout | unchanged |
| serverAuthoritative + stale cache claiming step-aside | โ | โ | must NOT step aside (O3) |
Also fixes to VERIFY separately (pre-existing, out of scope but adjacent):Console kill-switch gap (blocked box still reachable via console password) โ NOT this
change; track separately.
10. Build / ship notes#
Windows: FULL build โ x64 + Bridge + MSI. Do NOT -SkipBridgeRebuild (MSI ships
Core.dll from the Bridge build path). Bump CP DLL FileVersion or the MSI upgrade keeps
the old tile DLL. Verify the MSI artifact, not a mid-build copy.
Linux: rebuild pam_authyo + bump deb/rpm.
No git commit (owner commits manually).
Open questions โ RESOLVED by owner (2026-07-02)#
O1 โ YES. suspended steps aside too. Final rule: IsStepAside = NOT disabled
AND (activationStatus=="deactivated" OR licenseStatus โ {lapsed, suspended,
cancelled} OR (activeuntilperiodend AND period elapsed)). One consistent rule:
any non-active, non-killswitch state โ password login.
O2 โ (A) Read cached config directly. The filter reads the same cached fields the
agent writes each heartbeat (LastLicenseStatus, LastPeriodEndUnix, Activated,
Disabled). Reuses the existing kill-switch plumbing. Confirm/extend the C++ config
reader the tile/bridge already use.
O3 โ YES. Under serverAuthoritative, step-aside is honored ONLY from a fresh
authoritative cache โ same freshness gate as exempt/trust (Gatekeeper.cs:146-202,
AUTHYO_POLICY_MAX_AGE_SEC). A stale/offline cache must NOT be able to claim
step-aside and drop MFA. When serverAuthoritative is OFF (default), cached state is
sufficient.
O4 โ HIDE the Authyo tile. In step-aside only the normal Windows password tile
shows (no dead OTP tile). Implement via SetUsageScenario / GkConfigPresent-style
gating so our CP reports no tile in step-aside; the filter's un-hide (4b) exposes the
inbox password tile. No informational banner.
Implementation map (v1.0.42, 2026-07-02)#
Single source of truth: LicenseSection.IsStepAside(nowUnix) in
Authyo.Gatekeeper.Core/Config/GatekeeperConfig.cs. Rule: NOT Disabled AND
(LastActivationStatus=="deactivated" OR LastLicenseStatus โ
{lapsed,suspended,cancelled} OR (activeuntilperiodend AND now > LastPeriodEndUnix)).GatekeeperConfig.cs โ added LastActivationStatus field + IsStepAside().
ConfigManager.cs โ added StepAsideForCredentialUi(config, nowUnix) (adds the O3
serverAuthoritative 24h freshness gate; fails safe on error).
LicenseManager.cs โ heartbeat now caches LastActivationStatus.
Gatekeeper.cs โ gate returns BeginResult.Allowed (GATE_STEPASIDE) inside the
localShortCircuitsTrusted branch, ahead of exempt/trust (O3-safe).
Bridge/AuthyoGatekeeperBridge.{h,cpp} โ new GkStepAside(configPath) export.
CredentialProvider/OTPCredentialProviderFilter.cpp โ Filter() early-returns S_OK
(un-hides inbox password tile) on RDP when GkStepAside; UpdateRemoteCredential
returns S_OK (allows mstsc pre-fill) when GkStepAside (was always E_NOTIMPL).
CredentialProvider/OTPCredentialProvider.cpp โ SetUsageScenario hides our tile
(m_configPresent = present && !steppedAside).
Version bumped VERSION.txt 1.0.41 โ 1.0.42 (unversioned-DLL upgrade trap).
config_parser.h/.c โ added last_activation_status field + parse lastActivationStatus.
authyo-configure/config_writer.c โ persists lastActivationStatus.
authyo-gatekeeper-heartbeat/main.c โ caches last_activation_status from heartbeat.
pam_authyo.c โ pam_is_step_aside() helper; returns PAM_IGNORE inside the
local_short_circuits_trusted branch ahead of exempt/trust.
No server changes. Heartbeat already returned the needed fields.STILL TODO (not code โ follow-ups)#
Portal copy is now WRONG: Deactivate/Block tooltips say "keeps enforcing OTP".
After this ships they must be updated (server-side UI task, GatekeeperController /
views). Otherwise the portal lies.
Runtime test on Server 2022 per the ยง9 matrix โ especially the
UpdateRemoteCredential S_OK change on real RDP (riskiest line).
Linux deb/rpm version bump to match.
Modified atย 2026-07-02 06:05:00