Re-attest without a prompt
A device’s attestation key is created once, under a single elevation, at first enrollment. Using that key to attest is unprivileged, so a device that has attested before should re-attest with no prompt at all. Use the attest-first, enroll-on-miss pattern: try the unprivileged path first, and fall back to the one-time elevated enrollment only when the device genuinely needs it.
Creating the attestation key (TPM2_Create) and binding it (TPM2_ActivateCredential) require an elevated process on Windows. Everything after that is unprivileged: reading the endorsement key, and signing a fresh quote over your nonce (TPM2_Quote). So enroll once, attest forever. If your flow prompts for elevation on every attestation, it is re-enrolling when it should be re-using the existing key.
Which operations need elevation
Only the two enrollment operations elevate, and only on Windows. Design your integration so the elevated path runs at most once per device.
| Operation | TPM primitive | Elevation (Windows) | When it runs |
|---|---|---|---|
| EnrollBegin | Create + load AK | Required | First time a device is bound (and on a genuine re-bind, see below). |
| EnrollComplete | ActivateCredential | Required | Same elevation as EnrollBegin. Proves the new key is TPM-resident. |
| CollectEvidence | Quote over nonce | None | Every attestation. Uses the existing key, no prompt. |
| CollectPosture | Local readiness | None | Optional. Reports isEnrolled (a key exists locally) with no TPM op. |
Attest-first (browser SDK)
@rootherald/browser is published under the alpha dist-tag and still in development — the verbs below (enroll, attest, NotEnrolledError) are real and keyless, but the surface may change before GA. Server-side verification with @rootherald/node is the shipping default; see the browser SDK page for its status.
Call attest() first. It uses whatever key the device already has, with no elevation. The SDK throws NotEnrolledError only when the device has no key yet; that is your cue to run the one-time enroll() and retry. A known device never reaches the enroll branch, so it never prompts.
import { attest, enroll, NotEnrolledError } from "@rootherald/browser";
// `relay` bridges the two keyless enroll legs through YOUR backend (which holds
// the rh_sk_ secret). See the device-enrollment guide for the relay callbacks.
async function getEvidence(nonce: string, relay: EnrollRelay) {
try {
// Steady state: quote with the existing key. Unprivileged — no UAC.
return await attest(nonce);
} catch (err) {
if (err instanceof NotEnrolledError) {
// First run on this device only: enroll once (the single elevation)...
await enroll(relay);
// ...then attest with the freshly-bound key.
return await attest(nonce);
}
throw err; // ExtensionMissingError / HostMissingError → route to install steps
}
}Resist calling enroll()up front “to be safe.” That forces the elevation on every visit. Let a thrown NotEnrolledError (client) or an enrollmentRequired response (server, below) drive enrollment; both fire only on a real miss.
When the server no longer recognizes the device
The device can hold a valid local key that the serverdoesn’t know: it was never enrolled in this backend, its record was reset, or its key rotated. Your backend detects this from the verify response: enrollmentRequired: true. That is a machine-readable “unknown device” signal, distinct from a policy failure, so you enroll and retry rather than deny.
// Your server verifies the page-collected evidence with your rh_sk_ secret.
const res = await fetch("https://api.rootherald.io/api/v1/attestations/verify", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.RH_SECRET_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ challengeId, evidence }),
});
const { verdict, enrollmentRequired } = await res.json();
if (enrollmentRequired) {
// Unknown device (new here / record reset / key rotated). NOT a policy failure:
// tell the client to run enroll() and submit fresh evidence. One elevation.
return { action: "enroll-and-retry" };
}
if (verdict.device.earStatus !== "affirming") {
// A genuine policy failure (e.g. banned, not affirming). Do NOT re-enroll —
// re-enrolling won't change the verdict and only churns the key.
return { action: "deny", verdict };
}
return { action: "allow", verdict };Re-enroll only on NotEnrolledError (no local key) or enrollmentRequired: true(server doesn’t know the key). Never re-enroll on a policy failure or a transient error (a non-2xx / 5xx): a flapping backend would rotate the device’s key on every hiccup. Attest-first retries at most once. If the server still doesn’t recognize the device right after a successful enroll, surface an error instead of looping.
Native SDK (C / C++)
The same shape holds for embedders linking RootHerald.lib. CollectEvidence is unprivileged; a ROOTHERALD_ERR_NOT_ENROLLED return is the cue to enroll once under elevation and collect again. See Windows elevation patterns for how to acquire that single elevation.
char* evidence = NULL;
RootHeraldStatus st = RootHeraldClient_CollectEvidence(client, nonce, &evidence);
if (st == ROOTHERALD_ERR_NOT_ENROLLED) {
// No attestation key yet: enroll ONCE (EnrollBegin + EnrollComplete) from an
// elevated, resident worker — see "Windows elevation patterns" — then retry.
enroll_under_elevation(client); // your elevation strategy
st = RootHeraldClient_CollectEvidence(client, nonce, &evidence);
}
// st == ROOTHERALD_OK -> 'evidence' is the quote blob to hand to your backend.
RootHeraldClient_FreeEvidence(evidence);To decide before you call, RootHeraldClient_CollectPosture reports is_enrolled(a key exists locally) without touching the TPM, handy for showing “set up once” UI only when it’s actually needed.