Enrolling a Windows agent with one PowerShell line (and sealing the credential with DPAPI)
The enrollment UX for RFF works like this: you click “Install Agent,” a one-time command appears, you paste it into an elevated PowerShell on the target machine, and the agent is installed and enrolled in about 30 seconds. No shared secrets, no persistent tokens that can be copied and replayed:
irm 'https://get.getrff.com/install.ps1?t=rff_ent_...' | iex
Behind that one line is a four-step credential lifecycle: mint a short-lived single-use token, redeem it for a per-device API key, seal the key at rest with DPAPI, then blank the cleartext. Here’s how each step works and why it’s designed the way it is.
The two credentials and why they’re different
The system has two distinct secrets with different lifetimes and different jobs:
-
The enrollment token (
rff_ent_...): short-lived (15-60 minutes), single-use, burned at first contact. If it leaks, it’s dead within the hour. If someone races to redeem it before the real machine does, the real machine gets rejected but the operator can just generate a new one. -
The per-device API key: no TTL, machine-scoped, rotated periodically. If it leaks undetected, a TTL would expire healthy polling agents on a schedule - a self-inflicted outage with zero security benefit. Rotation on a regular schedule limits the blast radius without bricking the fleet.
These are not the same credential used in two ways. They solve different problems, and conflating their lifetimes is a mistake. The enrollment token is an invitation; the API key is the ongoing relationship.
Step 1: minting the token
When you click “Install Agent” in the console, the web app calls POST /api/enrollment/tokens. The server generates a 160-bit URL-safe random token:
var bytes = RandomNumberGenerator.GetBytes(20);
var b64 = Convert.ToBase64String(bytes)
.Replace('+', '-').Replace('/', '_').TrimEnd('=');
return "rff_ent_" + b64; // ~35 chars, ~160 bits entropy
The rff_ent_ prefix exists so you can identify the token type at a glance in logs, and so the server can validate the shape before even checking the database:
private static readonly Regex TokenShape =
new(@"^rff_ent_[A-Za-z0-9_-]+$", RegexOptions.Compiled);
if (token is null || token.Length is < 16 or > 64 || !TokenShape.IsMatch(token))
return Content(ErrorScript, "text/plain");
Only the SHA-256 hash is persisted. The raw token leaves the server once - in the API response to the console UI. If an attacker compromised the database, they’d have a list of hashes of already-burned tokens.
There’s a strict mode: if you provide a machine name when minting, the token is bound to that hostname. Any other machine that presents the token is rejected. It’s off by default because real fleets have messy naming (FQDN vs short hostname, AD-joined vs workgroup, renamed boxes). Strict is opt-in per-batch.
Step 2: the one-liner
The console builds the install command:
irm 'https://get.getrff.com/install.ps1?t=rff_ent_...' | iex
GET /install.ps1?t=<token> returns a short PowerShell script as text/plain. The script:
- Checks for elevation (exits loudly if not running as admin)
- Downloads the signed MSI via
Invoke-WebRequest - Verifies the Authenticode signature - if it’s not
Valid, refuses to proceed - Runs
msiexec /i rff-agent.msi /qn APIKEY=<token> APIURL=<origin> - Cleans up the MSI from temp
The API origin (APIURL) is baked from server config, not from the Host header. If we trusted the Host header, a reverse proxy misconfiguration or request with a spoofed Host could point the MSI download at an attacker-controlled URL.
The enrollment token appears in the command as a query parameter and in msiexec’s property list - both places it’s visible in logs and shell history. That’s intentional. Single-use means a token in a transcript is worthless after the machine has enrolled; there’s nothing to protect. The MSI carries the token only until first contact. After that, it’s burned.
Step 3: first contact (atomic single-use redemption)
The MSI custom action calls POST /api/agents/enroll with the token and the hostname. The server:
- Hashes the token and looks up the row
- Checks
Status == "pending"andExpiresAt > now - If strict mode, checks the hostname matches
- Does the atomic redemption:
var redeemedRows = await _db.EnrollmentTokens
.Where(t => t.Id == token.Id && t.Status == "pending")
.ExecuteUpdateAsync(s => s
.SetProperty(t => t.Status, "redeemed")
.SetProperty(t => t.UsedAt, DateTime.UtcNow)
.SetProperty(t => t.RedeemedByHostname, hostname));
if (redeemedRows == 0)
return Unauthorized(new { error = "This enrollment token has already been used." });
The WHERE t.Status = 'pending' in the UPDATE is the race serializer. Two concurrent redemption requests for the same token both hit step 1-3 successfully. One of them wins the UPDATE (1 row affected); the other gets 0 rows and is rejected. No advisory locks, no transactions beyond EF’s default - the conditional UPDATE is the entire concurrency model.
On success, the server generates a fresh 256-bit per-device API key, creates (or upserts) the agent row, and returns the raw key in the response. The MSI custom action writes it to appsettings.json under Agent:ApiKey. That’s the only moment it’s in cleartext on disk.
Step 4: DPAPI sealing and blanking the cleartext
The agent is installed as a Windows service running as LocalSystem. Before AgentApiClient is constructed - before any network call is possible - Program.cs calls CredentialStore.Resolve():
// Called before AgentApiClient construction so the resolved key
// is in config before the first HTTP request.
CredentialStore.Resolve(cfg, Path.Combine(contentRoot, "appsettings.json"), log);
services.AddSingleton<AgentApiClient>(); // picks up cfg.ApiKey after Resolve()
Resolve has three paths:
Fresh install / re-enroll: cleartext ApiKey is present in config. Seal it and blank it.
Steady state: no cleartext, but C:\ProgramData\RFF\agent\cred.dat exists. Unseal it and load it into config.
Neither: something went wrong during install. Let validation fail loudly rather than silently proceeding with no credential.
The sealing path:
private static void SealAndBlank(string apiKey, string appSettingsPath, ILogger log)
{
var sealedBytes = ProtectedData.Protect(
Encoding.UTF8.GetBytes(apiKey),
Entropy, // "RFF.Agent.Credential.v1"
DataProtectionScope.LocalMachine);
// Verify the round-trip BEFORE blanking.
// If DPAPI is broken, leave the cleartext in place and retry next boot.
var roundTrip = ProtectedData.Unprotect(sealedBytes, Entropy, DataProtectionScope.LocalMachine);
if (!Encoding.UTF8.GetString(roundTrip).Equals(apiKey, StringComparison.Ordinal))
{
log.LogError("Seal round-trip mismatch - leaving cleartext untouched.");
return;
}
// Atomic write: write to .tmp, then rename (NTFS rename is atomic)
AtomicWriteBytes(BlobPath, sealedBytes);
// Best-effort blank - retries next boot if this fails
TryBlankApiKeyInAppSettings(appSettingsPath, log);
}
private static readonly byte[] Entropy =
Encoding.UTF8.GetBytes("RFF.Agent.Credential.v1");
Three design choices worth explaining:
DataProtectionScope.LocalMachine, not CurrentUser. The agent runs as LocalSystem. A CurrentUser blob would be tied to the LocalSystem profile - survivable under normal operation, but fragile across service account changes, certain repair scenarios, and OS migrations. LocalMachine scope binds the blob to the hardware. An off-box copy (backup, imaging, MSI installer cache) is useless without the machine’s TPM-backed DPAPI master keys.
App entropy ("RFF.Agent.Credential.v1"). This isn’t a secret - it ships in the signed binary. The point isn’t to hide it from someone with full admin access to the machine; if they have that, they can call Unprotect themselves. The entropy value ensures that a generic “sweep all LocalMachine DPAPI blobs on this box” script can’t read the credential without knowing the app-specific entropy value. It’s defense-in-depth, not a primary control.
Verify-then-blank invariant. We only blank the cleartext after a successful round-trip decrypt. If ProtectedData.Protect succeeds but Unprotect fails (a DPAPI misconfiguration, or a race with a key rotation), we leave the cleartext in appsettings.json and log an error. The agent retries the seal on next boot. This prevents a bricked state where the cleartext is gone but the blob is unreadable. Bricking a fleet agent is worse than leaving a cleartext file in Program Files for one more boot cycle.
That verify-before-you-destroy instinct isn’t unique to the agent. The server side runs the same play for its Data Protection key ring - and we learned it the hard way when a deploy wiped the ring and silently broke everyone’s MFA.
The blob location
The sealed blob lives at C:\ProgramData\RFF\agent\cred.dat - outside the MSI’s installation directory (C:\Program Files\RFF\). This is intentional: MSI repairs and uninstalls touch the installation directory. A repair could reinstall appsettings.json with a blank ApiKey and not touch the blob, meaning the agent picks up the sealed credential on next boot without re-enrolling. The blob survives the MSI lifecycle.
What the attacker model looks like
If someone extracts the MSI or intercepts the install command, they get a short-lived single-use token. If the real machine hasn’t enrolled yet, they could enroll a rogue agent under the tenant - but the console shows every connected agent, the token mint and redemption are both audit-logged with source IP and hostname, and the token is dead after use regardless.
If someone gets read access to appsettings.json after first boot, ApiKey is blank. If they get cred.dat, they have a DPAPI blob that only decrypts on the machine it was sealed on, with the app entropy. If they have enough access to call ProtectedData.Unprotect on the machine, they have LocalSystem - the credential is the least of your problems at that point.
The Updater doesn’t need the key
One constraint that makes the blanking safe: the Updater process (which handles agent self-updates) reads only Agent:ApiBaseUrl from appsettings.json. The MSI download for self-updates is anonymous. So blanking Agent:ApiKey doesn’t break updates. The only process that needs the credential is the main agent worker, and it picks it up from the sealed blob.
RFF is a Windows fleet management tool - patch, deploy, audit, remote desktop, all from a browser tab, no VPN. getrff.com