Skip to content
Guides

Enrolment

A device enrols once. Two blobs cross your backend; the client never sees rh_sk_ and never talks to Root Herald.

server (Node) — relay the two legsts
import { RootHeraldClient, AdmissionRefusedError } from "@rootherald/node";
const rh = new RootHeraldClient({ secretKey: process.env.RH_SECRET_KEY! });

// Leg 1. The client's EnrollBegin blob, plus the id of the challenge it was answering.
app.post("/api/enroll", async (req, res) => {
  const { enrollRequestBlob, challengeId } = req.body;
  try {
    const { deviceId, challenge } = await rh.relayEnroll(enrollRequestBlob, { challengeId });
    res.json({ deviceId, challenge });          // hand `challenge` to the client's EnrollComplete
  } catch (err) {
    // 422 admission_refused: the challenge's policy does not admit this TPM class.
    // Not retriable on this device; no elevation prompt was spent.
    if (err instanceof AdmissionRefusedError) return res.status(403).json({ error: "device_not_eligible" });
    throw err;
  }
});

// Leg 2. The client's EnrollComplete blob.
app.post("/api/activate", async (req, res) => {
  const { deviceId } = await rh.relayActivate(req.body.activationResponse);
  res.json({ deviceId });
});

When to enrol

Never up front. Call verify(); a device that has never been seen comes back enrollmentRequired: true, and the client's respond() throws NotEnrolledError (native: RH_ERR_NOT_ENROLLED). Either is the cue. Enrol, then retry the same challenge. A fail verdict on an enrolled device is a policy outcome — re-enrolling changes nothing. See re-attestation.

Why two legs

Leg 1 sends the endorsement key certificate and the new attestation key's public area. Root Herald validates the EK chain to a manufacturer root, classifies the chip, runs admission under the challenge's policy, and returns a TPM2_MakeCredential challenge only this EK can open. Leg 2 returns the decrypted secret, proving the attestation key lives in the same chip as the EK. Both legs run on one session; on Windows that session must be elevated (how).

Browser

page.tsts
import { respond, enroll, NotEnrolledError, AdmissionRefusedError } from "@rootherald/browser";

try {
  ({ evidence } = await respond(challenge));
} catch (err) {
  if (!(err instanceof NotEnrolledError)) throw err;
  try {
    await enroll({
      enroll: (blob) => post("/api/enroll", { enrollRequestBlob: blob, challengeId }),
      activate: (blob) => post("/api/activate", { activationResponse: blob }),
    });
  } catch (e) {
    if (e instanceof AdmissionRefusedError) return showNotEligible();   // policy said no, before any prompt
    throw e;
  }
  ({ evidence } = await respond(challenge));
}

Native

enroll.c — same session, same process, elevated on Windowsc
char buf[8192]; size_t len = 0;
RH_STATUS st = RootHeraldEnrollBegin(h, buf, sizeof buf, &len);
if (st == RH_ERR_ELEVATION_REQUIRED) return relaunch_elevated();   /* Windows only */
if (st != RH_OK) return st;

/* Relay buf (JSON) + challengeId to your backend → rh.relayEnroll(). An empty
   reply means 422 admission_refused: stop here, no second leg. */
std::string cred = backend_relay_enroll(std::string(buf, len - 1), challenge_id);
if (cred.empty()) return RH_ERR_INTERNAL;

st = RootHeraldEnrollComplete(h, cred.data(), cred.size(), buf, sizeof buf, &len);
if (st != RH_OK) return st;                     /* credential is single-use: restart from EnrollBegin */
backend_relay_activate(std::string(buf, len - 1));   /* → rh.relayActivate() */

On Windows the two calls need one elevated process, and EnrollComplete must run on the session that ran EnrollBegin. Patterns for acquiring that elevation are on the Windows elevation page. Linux and macOS never elevate.

iOS

One leg. enroll(to: challenge, challengeId:) attests the App Attest key and returns the body your backend posts to POST /api/v1/attest/enroll; there is no activate leg because Apple's challenge is a plain nonce. Details on the iOS SDK page.

The per-tenant device id

deviceId from the relay and device.ueid from every verdict are the same value: derived from the hardware identity keyed with a secret unique to your tenant. Another customer verifying the same machine sees a different id. It survives OS reinstall and attestation-key rotation, and carries no PII.

Enrolment binds a chip, not a user

It needs no logged-in user and can run from an installer. Deciding which devices are yours is your policy: gate the enrolment moment with a secret you deliver only to sanctioned machines, record device.ueid on the first passing verify, and gate later requests on that set.

server — trust on first use with your own secretts
app.post("/api/onboard", async (req, res) => {
  const { challengeId, evidence, enrollToken } = req.body;      // enrollToken via MDM or an authenticated link
  const result = await rh.verify(evidence, { challengeId });
  if (result.device.verdict !== "pass") return res.status(403).end();

  const row = await db.enrollTokens.findUnused(sha256(enrollToken));
  if (!row || row.expiresAt < Date.now()) return res.status(403).end();

  await db.enrolledDevices.upsert({ ueid: result.device.ueid });
  await db.enrollTokens.markUsed(row.id);                       // one secret, one machine
  res.json({ enrolled: true });
});