Skip to content
Get started

Enroll and verify a device

One backend file, one page file. The backend holds the rh_sk_ key and does the appraisal; the page collects evidence and never sees a verdict.

1

Install

terminalbash
npm install @rootherald/node express     # backend
npm install @rootherald/browser           # page

Create a project in the dashboard and copy its secret key (rh_sk_…). @rootherald/browser is not yet on npm; the API below is its shipping surface, and the chain runs today at /try.

2

Backend

server.tsts
import express from "express";
import { RootHeraldClient, AdmissionRefusedError, QuotaExceededError } from "@rootherald/node";

const rh = new RootHeraldClient({
  secretKey: process.env.RH_SECRET_KEY!,   // rh_sk_…
  baseUrl: process.env.RH_BASE_URL,        // undefined → https://rootherald.io
});
const app = express().use(express.json({ limit: "1mb" }));

// Mint a challenge. Relay `challenge` to the page verbatim; keep challengeId for verify.
app.post("/api/challenge", async (_req, res) => {
  const { challengeId, challenge } = await rh.issueChallenge({
    ask: ["identity", "posture"],                 // the default when omitted
    policy: "rootherald:builtin:strict-hardware", // verify may tighten this, never loosen it
  });
  res.json({ challengeId, challenge });
});

// Appraise the evidence the page posts back.
app.post("/api/verify", async (req, res) => {
  const { challengeId, evidence } = req.body;
  let result;
  try {
    result = await rh.verify(evidence, { challengeId });
  } catch (err) {
    if (err instanceof QuotaExceededError) return res.status(429).end();
    throw err;                                   // auth / challenge / evidence errors
  }
  if (result.enrollmentRequired) return res.status(409).json({ error: "enrollment_required" });
  if (result.device.verdict !== "pass") return res.status(403).json({ error: "device_rejected" });
  res.json({ deviceId: result.device.ueid });    // stable per-tenant device id
});

// First contact only: relay the two enroll legs. Admission runs under the
// challenge's policy, so a refused TPM class never spends an elevation prompt.
app.post("/api/enroll", async (req, res) => {
  const { enrollRequestBlob, challengeId } = req.body;
  try {
    res.json(await rh.relayEnroll(enrollRequestBlob, { challengeId }));
  } catch (err) {
    if (err instanceof AdmissionRefusedError) return res.status(403).json({ error: "device_not_eligible" });
    throw err;
  }
});
app.post("/api/activate", async (req, res) => {
  res.json(await rh.relayActivate(req.body.activationResponse));
});

app.listen(3000);
3

Page

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

async function post(url: string, body: unknown) {
  const r = await fetch(url, {
    method: "POST",
    headers: { "content-type": "application/json" },
    body: JSON.stringify(body),
  });
  if (!r.ok) throw new Error((await r.json()).error);
  return r.json();
}

export async function verifyThisDevice() {
  const { challengeId, challenge } = await post("/api/challenge", {});

  let evidence;
  try {
    ({ evidence } = await respond(challenge));   // enrolled device: no prompt
  } catch (err) {
    if (!(err instanceof NotEnrolledError)) throw err;
    // First contact: enroll once, relaying both legs through the backend, then answer again.
    await enroll({
      enroll: (enrollRequestBlob) => post("/api/enroll", { enrollRequestBlob, challengeId }),
      activate: (activationResponse) => post("/api/activate", { activationResponse }),
    });
    ({ evidence } = await respond(challenge));
  }

  return post("/api/verify", { challengeId, evidence });   // { deviceId }
}
4

Run

terminalbash
RH_SECRET_KEY=rh_sk_… npx tsx server.ts
# against a local stack instead of production:
RH_BASE_URL=http://localhost RH_SECRET_KEY=rh_sk_… npx tsx server.ts
What you have

A device that has never been seen enrolls once (one UAC on Windows), then every call is an unprivileged quote. deviceId is the handle for one-device-one-account, rate limits, or a ban list. Change the ask to ["key"] and the same two calls hand you a device-bound signing key.