Skip to content
Guides

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.

page.ts — attest-first, enrol on missts
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

CallTPMElevation (Windows)Runs
EnrollBegin / EnrollCompleteCreate AK, ActivateCredentialRequired, one sessionOnce per device.
RespondQuote (+ Create/Certify for a key ask)NoneEvery attestation.
LoadKey / SignLoad, TPM2_SignNoneEvery signed request.
PreCheckNoneNoneReports 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

server (Node)ts
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 };
Re-enrol only on a genuine miss

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.

server (Node)ts
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.

server (Node)ts
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

attest.cc
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.