SurePassID Offline OTP and Offline FIDO2

SurePassID Authentication Server

Offline Authentication: Offline OTP and Offline FIDO2

Summary

The SurePassID Windows Login Manager enforces MFA at the Windows logon, unlock, and UAC screens. When the SurePassID MFA server is unreachable (network outage, remote/travelling laptop, isolated machine), two offline mechanisms allow the second factor to still be verified locally:

  • Offline OTP — a cache of pre-computed one-time passcodes is stored on the machine after a successful online authentication and consumed one-at-a-time while offline.
  • Offline FIDO2 — FIDO2 credential descriptors and public keys are cached after successful online authentication, and security-key assertions are verified locally (signature + counter) while offline.

Both caches are populated only during a successful online authentication, are encrypted with Windows DPAPI, are policy-gated, and emit Windows Event Log security events for monitoring.


Online vs. offline flow

The provider first checks whether the MFA server is reachable (SPUtil::IsMfaServerReachable). That single decision routes the request down the online or offline path. The status line on the tile reflects the result (see §1.4).

flowchart TD
    A[User enters username + password] --> B{MFA server reachable?}

    B -- Yes --> C[ONLINE path]
    C --> C1[Validate 2nd factor with server<br/>OTP / Push / FIDO2]
    C1 --> C2{Success?}
    C2 -- No --> CF[Deny logon<br/>MSG_AUTHENTICATION_FAILED]
    C2 -- Yes --> C3[Refresh local cache]
    C3 --> C3a[OTP: if AllowOffLineAuth=0 and<br/>server returned codes -> SaveOfflineCache<br/>DPAPI-encrypt OfflineCache]
    C3 --> C3b[FIDO2: if AllowOfflineFido2=1 -><br/>SaveFromOptionsResponse + SavePublicKey<br/>DPAPI-encrypt Fido2OfflineCredentials]
    C3a --> COK[Serialize credential -> logon]
    C3b --> COK

    B -- No --> D[OFFLINE path<br/>MSG_MFA_SERVER_UNREACHABLE]
    D --> E{Offline mode<br/>AllowOffLineAuth}

    E -- "0 (Offline 2FA)" --> F{Which cached factor?}
    F -- FIDO2 enabled + cached --> F1[VerifyAssertionLocally<br/>signature + counter + RP/UP]
    F -- OTP cache present --> F2[ValidateOffLineOtp<br/>match code, advance index]
    F1 --> G{Valid?}
    F2 --> G
    G -- No --> GF[Deny / exhausted<br/>MSG_OFFLINE_CACHE_EXHAUSTED]
    G -- Yes --> GOK[Serialize credential -> logon]

    E -- "2 (Offline 1FA)" --> H[Password-only fallback<br/>no 2nd factor offline]
    H --> GOK

    E -- "1 (Online 2FA only)" --> I[No offline fallback<br/>Deny until online]

Notes:

  • Offline OTP is only available in mode 0 (OFFLINE_OPTION_OFFLINE2FA).
  • Offline FIDO2 is controlled separately by AllowOfflineFido2 and works whenever a FIDO2 cache exists, independent of the OTP mode.
  • The cache is written only on the ONLINE success path; the offline path only consumes it (advancing the OTP index / bumping the FIDO2 counter).

1. How it works

1.1 Why the cache lives in HKLM and is machine-bound

At the logon screen the credential provider runs as SYSTEM, so HKCU resolves to the SYSTEM profile, not the user signing in. The offline caches are therefore stored under HKLM\SOFTWARE\SurePassId\CredProv and encrypted with DPAPI using CRYPTPROTECT_LOCAL_MACHINE, which binds the ciphertext to the machine (not a user session) so SYSTEM can decrypt it at the logon screen.

Registry layout:

HKLM\SOFTWARE\SurePassId\CredProv
    OfflineCache            = <DPAPI-encrypted, comma-separated OTP codes>
    OfflineCacheIndex       = <integer: index of next unused code>
    Fido2OfflineCredentials = <DPAPI-encrypted JSON blob>
    Fido2OfflineLastSync    = <Unix timestamp of last online FIDO2 auth>

See docs/PerUserOfflineCache_DesignDoc.md for the per-user cache design that addresses shared-machine scenarios.

1.2 Offline OTP

Populate (online): After a successful online authentication the server returns a set of future OTP values. CSurePassClientLib::SaveOfflineCache encrypts them with DPAPI (SPUtil::EncryptForCache) and writes them to OfflineCache, resetting OfflineCacheIndex as appropriate.

Consume (offline): CSurePassClientLib::ValidateOffLineOtp:

  1. Reads and DPAPI-decrypts OfflineCache (SPUtil::DecryptFromCache, which also transparently handles the legacy cache format).
  2. Reads OfflineCacheIndex (the first still-valid code).
  3. If index >= count, the cache is exhausted — a security event is raised and offline auth is denied until the user re-authenticates online.
  4. Scans from index forward for a matching code. On a match it advances OfflineCacheIndex to matched + 1 so each cached code is single-use (a used or skipped code can never be replayed).

Format: OfflineCache is a comma-separated list of OTP codes; a monotonic OfflineCacheIndex marks consumption.

1.3 Offline FIDO2

Implemented in SPWebAuthnLib/SPFido2OfflineCache.

Populate (online):

  • SaveFromOptionsResponse caches the relying-party ID and credential descriptors (credential IDs, type, transports) from the assertion options.
  • SavePublicKey caches the COSE public key and algorithm (e.g. -7 ES256, -257 RS256) for each credential returned by the server, preserving the lastCounter.
  • The whole JSON blob is DPAPI-encrypted and stored in Fido2OfflineCredentials; Fido2OfflineLastSync records the sync time.

Consume (offline):

  • LoadCachedOptionsResponse synthesizes an assertion-options response and generates a fresh 32-byte random challenge with BCryptGenRandom.
  • VerifyAssertionLocally verifies the authenticator assertion using CNG (BCryptVerifySignature):
    1. Parse authenticatorData, clientDataJSON, signature, credentialId.
    2. Look up the cached public key + algorithm for that credential.
    3. Compute SHA-256(clientDataJSON).
    4. signed_data = authenticatorData || client_data_hash.
    5. Verify the RP ID hash (first 32 bytes of authenticatorData).
    6. Check the User Present (UP) flag.
    7. Counter anti-replay: the assertion counter must be greater than the cached lastCounter (unless the authenticator reports 0, meaning it does not implement counters). A counter regression is rejected.
    8. Verify the signature; on success update lastCounter.

Decrypted cache JSON (illustrative):

{
  "rpId": "mfa.surepassid.com",
  "credentials": [
    {
      "id": "<base64url credential ID>",
      "type": "public-key",
      "publicKey": "<base64url public key>",
      "algorithm": -7,
      "transports": ["usb", "nfc"],
      "lastCounter": 42
    }
  ],
  "cachedAt": "1719500000"
}

1.4 Online/offline status shown in the UI

The credential tile displays a status line (SFI_LOGONSTATUS_TEXT) so the user immediately knows whether they are online and, when offline, which second factor is available. It is set during Initialize and refreshed by UpdateLogonStatusText; the text comes from GetLogonStatusText, which checks server reachability plus the enabled offline methods and their cache state:

Condition Status text shown
MFA server reachable Server: Online
Offline, FIDO2 cache and OTP cache available Offline: Security key or passcode
Offline, FIDO2 cache available Offline: Security key
Offline, OTP cache available (AllowOffLineAuth = 0) Offline: Passcode required
Offline, single-factor mode (AllowOffLineAuth = 2) Offline: Single-factor access
Offline, no offline method available Offline: No access available

The offline options reflected here are: SPFido2OfflineCache::IsOfflineFido2Enabled() + HasCachedCredentials() for FIDO2, and GetOfflineOtpOption() + CSurePassClientLib::HasOfflineOtpCache() for OTP. Offline: No access available means offline auth is not configured or the machine has no cache yet (no prior online sign-in).

1.5 When and how the local cache is updated

The caches are only written during a successful online authentication — there is no background refresh. Each online sign-in replaces (not appends to) the relevant cache.

Offline OTP cache update:

  • Trigger: a successful online OTP validation (ValidateUserOtp in SurePassIdCredential.cpp), and the offline mode is OFFLINE_OPTION_OFFLINE2FA (AllowOffLineAuth = 0), and the server returned a non-empty offline_codes list in the validation response.
  • How: the server-supplied future codes are passed to CSurePassClientLib::SaveOfflineCache, which DPAPI-encrypts them (SPUtil::EncryptForCache) and writes OfflineCache. The consumption pointer OfflineCacheIndex tracks how many have been used since.
  • If the mode is not OFFLINE_OPTION_OFFLINE2FA, no OTP cache is written (and any existing one is invalidated at next logon — see §3.3).

Offline FIDO2 cache update (two steps, both online):

  1. Descriptors — on a successful online assertion options call (ServerPublicKeyCredentialGetOptions, error_code == 0) and only when IsOfflineFido2Enabled(), SaveFromOptionsResponse caches the RP ID and the allowed credential descriptors (IDs, type, transports).
  2. Public key — on a successful online assertion result (Fido2AssertionResult) when the server returns an offlineCredential, SavePublicKey stores/updates that credential's public key, algorithm and lastCounter.

Both steps DPAPI-encrypt the JSON blob into Fido2OfflineCredentials and update Fido2OfflineLastSync.

Consumption vs. update:

  • Using an offline OTP advances OfflineCacheIndex (each code single-use); it does not add new codes — only a later online sign-in refills them.
  • Using an offline FIDO2 key updates that credential's lastCounter (replay protection); the cached descriptors/public key are otherwise refreshed only on the next online sign-in.

Invalidation (removal) happens at the start of a logon/unlock session when policy no longer permits the method — see §3.3.


2. Configuration

All values are read through SPRegistry/ConfigurationManager, so a value set via Group Policy (HKLM\SOFTWARE\Policies\SurePassId\CredProv) overrides the local value. See docs/GPO_SETUP_GUIDE.md.

2.1 Offline OTP — AllowOffLineAuth

AllowOffLineAuth is a tri-state that maps to the offline OTP option via ConfigurationManager::GetOfflineOtpOption():

AllowOffLineAuth Mode constant Meaning
"0" OFFLINE_OPTION_OFFLINE2FA Offline two-factor using the cached OTP codes (OTP cache required).
"1" (default) OFFLINE_OPTION_2FA Online two-factor; offline OTP cache not used for fallback.
"2" OFFLINE_OPTION_1FA Offline single-factor fallback (password only when offline); OTP cache not used.

When the effective mode does not require the OTP cache, any existing cache is proactively invalidated (see §3.2).

2.2 Offline FIDO2 — AllowOfflineFido2

Value Meaning
AllowOfflineFido2 = "1" Offline FIDO2 verification enabled.
"0" or absent (default) Offline FIDO2 disabled (secure by default); any cache is invalidated.

SPFido2OfflineCache::IsOfflineFido2Enabled() reads this flag (REG_ALLOW_OFFLINE_FIDO2). It is independent of AllowOffLineAuth — OTP and FIDO2 offline are controlled separately.

Value Purpose
DisableMfaServerConnectivity Controls the reachability check that decides whether the online or offline path is taken.
TraceOfflineCache Enables verbose tracing of offline-cache operations.

3. Security

3.1 Encryption at rest

  • Both caches are encrypted with DPAPI (CRYPTPROTECT_LOCAL_MACHINE) plus an entropy string, so the ciphertext is bound to the machine and unreadable if copied elsewhere.
  • SPUtil::EncryptForCache / SPUtil::DecryptFromCache centralize encryption. DecryptFromCache also accepts the legacy format for transparent migration; new writes are always DPAPI.
  • If DPAPI encryption fails, a MSG_DPAPI_ENCRYPTION_FAILED security event is raised and the code falls back to the legacy scheme so the user is not locked out.

3.2 Anti-replay and single-use

  • OTP: OfflineCacheIndex advances past every matched code, so each cached OTP is strictly single-use and cannot be replayed. When the list is exhausted, offline auth is denied until the next online sync.
  • FIDO2: the assertion signature counter must strictly increase over the cached lastCounter; regressions (a sign of a cloned authenticator) are rejected. The RP ID hash and User Present flag are also validated, and the signature is verified cryptographically with CNG.

3.3 Policy enforcement / cache invalidation

If offline authentication is later disabled by policy, stale caches are removed so they cannot be used:

  • CSurePassClientLib::InvalidateOtpCacheIfDisabled() clears the OTP cache when the effective mode does not use it (ClearOtpCache zeroes OfflineCache and resets OfflineCacheIndex).
  • SPFido2OfflineCache::InvalidateCacheIfDisabled() clears the FIDO2 cache when AllowOfflineFido2 is disabled (ClearCache).

These run at the start of each logon/unlock session from SurePassIdProvider::SetUsageScenario (CSurePassClientLib::InvalidateOtpCacheIfDisabled and SPFido2OfflineCache::InvalidateCacheIfDisabled), so a policy change takes effect on the next logon without requiring an online round-trip.

3.4 Secure by default

  • Offline FIDO2 is off unless explicitly enabled.
  • The caches only ever contain future OTP values / FIDO2 public keys (public data) — never passwords or FIDO2 private keys.

4. Monitoring

Offline authentication is auditable through the Windows Event Log (source SurePassID WLM, category Security). Relevant events:

Event ID (hex) Meaning
MSG_OFFLINE_AUTH_ATTEMPTED 0x400207D1 An offline authentication was attempted.
MSG_OFFLINE_CACHE_EXHAUSTED 0x800207D2 The offline OTP cache is used up; the user must authenticate online to replenish it.
MSG_OFFLINE_CACHE_INVALIDATED 0x800207DA An offline cache (OTP or FIDO2) was invalidated because offline authentication was disabled by policy.
MSG_MFA_SERVER_UNREACHABLE 0x800207D3 The MFA server could not be reached (triggers the offline path).
MSG_DPAPI_ENCRYPTION_FAILED 0x800207D8 DPAPI encryption of a cache failed; fell back to the legacy scheme.
MSG_AUTHENTICATION_SUCCEEDED / MSG_AUTHENTICATION_FAILED 0x400207D6 / 0xC00207D5 Final authentication result (covers both online and offline paths).

Additional diagnostics: set TraceOn = 1 (and optionally TraceOfflineCache = 1) to capture detailed offline-cache tracing in the SurePassID log. Sensitive values (OTP codes, cache contents) are masked unless trace level is explicitly raised.

What to alert on

  • MSG_OFFLINE_CACHE_EXHAUSTED — users repeatedly hitting this may indicate prolonged connectivity loss; they must reconnect to replenish.
  • MSG_OFFLINE_CACHE_INVALIDATED — confirms a policy tightening took effect; unexpected occurrences warrant review.
  • MSG_DPAPI_ENCRYPTION_FAILED — indicates a platform/DPAPI problem worth investigating.
  • FIDO2 counter regression (logged as an error during VerifyAssertionLocally) — possible cloned-authenticator indicator.

5. Component reference

File Responsibility
SurePassIdLib/SPRegistry.* Registry read/write; GPO precedence.
ConfigurationManager.* GetOfflineOtpOption() and related config reads.
CSurePassClientLib.* OTP cache: Save, Validate, HasCache, Clear, Invalidate.
SurePassIdLib/SPUtil (EncryptForCache/DecryptFromCache) DPAPI encryption of caches.
SPWebAuthnLib/SPFido2OfflineCache.* FIDO2 cache: Save, Load, VerifyAssertionLocally, Clear, Invalidate.
SurePassIdProvider.cpp Invalidates caches at session start.
SurePassIdCredential.cpp Drives OTP validate/save and offline status UI.
SurePassIdLib/SPEventMessages.h Security event definitions.
  • docs/PerUserOfflineCache_DesignDoc.md — per-user offline cache design.
  • docs/CONFIGURATION.md — full configuration reference.
  • docs/GPO_SETUP_GUIDE.md — managing settings via Group Policy.
  • docs/ENFORCE_ADMIN_MFA.md — how admin MFA enforcement interacts with offline fallback.

SurePassID 360 Central Avenue #800 St. Petersburg, FL 33701 USA +1 (888) 200-8144 surepassid.com