Re-attestation and step-up
Enrolment is the one elevated step; answering a challenge is unprivileged forever. Every later check is the same two calls with a different ask.
import { respond, enroll, NotEnrolledError } from "@rootherald/browser";
async function evidenceFor(challengeId: string, challenge: string) {
try {
return (await respond(challenge)).evidence; // known device: no prompt
} catch (err) {
if (!(err instanceof NotEnrolledError)) throw err; // ExtensionMissingError / HostMissingError → install steps
await enroll(relayThroughYourBackend(challengeId)); // once per device
return (await respond(challenge)).evidence;
}
}Which operations elevate
| Call | TPM | Elevation (Windows) | Runs |
|---|---|---|---|
| EnrollBegin / EnrollComplete | Create AK, ActivateCredential | Required, one session | Once per device. |
| Respond | Quote (+ Create/Certify for a key ask) | None | Every attestation. |
| LoadKey / Sign | Load, TPM2_Sign | None | Every signed request. |
| PreCheck | None | None | Reports is_enrolled, app_keys_supported. |
A flow that prompts on every attestation is re-enrolling when it should be re-using the key.
When the server no longer knows the device
const result = await rh.verify(evidence, { challengeId });
if (result.enrollmentRequired) return { action: "enroll-and-retry" }; // unknown here, record reset, key rotated
if (result.device.earStatus !== "affirming") return { action: "deny", result }; // policy: do NOT re-enrol
return { action: "allow", result };NotEnrolledError (no local key) or enrollmentRequired (server does not know the key). Never on a policy failure or a 5xx: a flapping backend would rotate the device's identity on every hiccup. Retry at most once; if the server still does not recognise the device after a successful enrol, surface an error.
Step-up before a sensitive action
A fresh posture ask under a strict policy, with the claim named explicitly. The verdict is fresh for about five minutes (verdict.expiresAt); act on it, do not cache it.
app.post("/api/stepup/challenge", async (_req, res) => {
const { challengeId, challenge } = await rh.issueChallenge({
ask: ["posture"],
policy: "rootherald:builtin:strict-hardware",
});
res.json({ challengeId, challenge });
});
app.post("/api/transfer", async (req, res) => {
const { challengeId, evidence, amount, to } = req.body;
const result = await rh.verify(evidence, { challengeId });
const secureBoot = result.assuranceClaimsMet?.includes("rootherald:assurance:secure-boot");
if (result.device.verdict !== "pass" || !secureBoot) return res.status(403).json({ error: "requires_secure_hardware" });
if (result.device.ueid !== req.session.boundDeviceId) return res.status(403).json({ error: "device_not_bound" });
await ledger.transfer(req.session.userId, to, amount);
res.json({ ok: true });
});Device binding
The cheapest composition: an identity ask (a quote over PCR 7, no event log) at signup, device.ueid stored on the account, and a refusal at login when the id differs. A stolen cookie or exported session fails on the attacker's machine because it cannot produce this chip's quote.
const result = await rh.verify(evidence, { challengeId }); // challenge minted with ask: ["identity"]
if (result.device.verdict !== "pass" || result.device.ueid !== user.boundDeviceId) {
return res.status(403).json({ error: "device_not_bound" }); // right password, wrong chip
}Native
RH_STATUS st = RootHeraldRespond(h, challenge, NULL, ev, sizeof ev, &len, NULL, 0, NULL);
if (st == RH_ERR_NOT_ENROLLED) {
enroll_under_elevation(challenge_id); /* EnrollBegin + EnrollComplete, once */
st = RootHeraldRespond(h, challenge, NULL, ev, sizeof ev, &len, NULL, 0, NULL);
}RootHeraldPreCheck reports is_enrolled without a TPM operation, for showing set-up UI only when it is needed. Elevation strategies are on the Windows elevation page.