Verify a device from a web app
The full client + server flow for a web app: your backend issues a challenge, the page collects a fresh TPM quote through the Root Herald extension, and your backend appraises the evidence. The client holds no key — your rh_sk_ secret never leaves your server.
The server half — @rootherald/node — is available on npm. The client half is in preview: the @rootherald/browser package is in development and not yet published to npm, and the Root Herald browser extension is not yet in the Chrome/Edge/Firefox stores. Do not expect npm install @rootherald/browser to resolve yet. The full chain works today, on real hardware — try it live at /try. The steps below are the integration you wire once both pieces publish; the API names are the real, shipping surface.
[server] Set up the server SDK
What to do → create a project in the dashboard, copy its rh_sk_… secret key, and install the server SDK (this one is live).
npm install @rootherald/nodeWhat you get → a RootHerald client your backend uses to open and close every attestation. Why → the secret and the appraisal live only on your server; the browser is untrusted and keyless.
import { RootHerald } from "@rootherald/node";
const rh = new RootHerald({ secretKey: process.env.RH_SECRET_KEY! }); // rh_sk_…[server] Issue a challenge and relay the nonce
What to do → expose an endpoint that mints a single-use challenge and returns it to your page.
app.post("/api/challenge", async (_req, res) => {
const { challengeId, nonce } = await rh.issueChallenge();
// Relay both to the page. Keep challengeId server-side too if you prefer;
// you need it again at verify time.
res.json({ challengeId, nonce });
});What you get → { challengeId, nonce }. Why → the client will quote the TPM over nonce, binding the evidence to this exact request so it can't be replayed.
[client install] The user needs the extension + native host
What to do → the end user installs the Root Herald browser extension and the native messaging host once. The extension bridges your page to the host; the host drives the local TPM. Why →a web page can't reach the TPM directly, so the extension + host are the trusted local collector.
There isn't a standalone “how the extension and host work” page yet. The closest references that exist today: the browser SDK page (the page ↔ extension ↔ host bridge and the two client verbs), the device-enrollment guide (trust-on-first-use and the first-attestation bootstrap), and the working chain you can drive end to end at /try. Until the extension is in the stores, treat /try as the reference install.
[client] Collect evidence in the page
What to do → fetch a challenge from your backend, then call attest(nonce). It returns the opaque evidence blob — the page holds no Root Herald key and gets no verdict.
import {
attest,
ExtensionMissingError,
HostMissingError,
NotEnrolledError,
TimeoutError,
} from "@rootherald/browser";
async function verifyThisDevice() {
// Beat 1+2: your backend issued the challenge.
const { challengeId, nonce } = await fetch("/api/challenge", {
method: "POST",
}).then((r) => r.json());
let evidence;
try {
// Beat 3: collect a fresh TPM quote over the nonce. Opaque blob, no verdict.
evidence = await attest(nonce);
} catch (err) {
if (err instanceof ExtensionMissingError) {
return promptInstallExtension(); // route the user to install the extension
}
if (err instanceof HostMissingError) {
return promptInstallHost(); // extension present, native host missing
}
if (err instanceof NotEnrolledError) {
// First time on this device — bootstrap the key, then retry attest().
return runEnrollThenRetry(nonce);
}
if (err instanceof TimeoutError) {
return showRetry(); // a TPM quote can be slow; let them retry
}
throw err;
}
// Beat 4: hand { challengeId, evidence } to YOUR backend (next step).
return fetch("/api/verify", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ challengeId, evidence }),
}).then((r) => r.json());
}What you get → an opaque evidence blob (plus typed errors that map to concrete fixes). Why → each error routes the user to the right recovery — install the extension, install the host, enroll, or retry — instead of a dead end.
[wire] Post the blob to your backend
What to do → POST { challengeId, evidence } to your own endpoint (done inline above). Why → only your backend carries rh_sk_, so only it can appraise the blob.
[server] Appraise the evidence and act
What to do → call rh.verify(evidence, { challengeId, policy }) and branch on the verdict.
import { QuotaExceededError } from "@rootherald/node";
app.post("/api/verify", async (req, res) => {
const { challengeId, evidence } = req.body;
let verdict;
try {
verdict = await rh.verify(evidence, {
challengeId,
policy: "rootherald:builtin:strict-hardware",
});
} catch (err) {
if (err instanceof QuotaExceededError) return res.status(429).end();
throw err; // InvalidSecretKeyError, ChallengeError, InvalidEvidenceError…
}
if (verdict.device.verdict !== "pass") {
return res.status(403).json({ error: "device_check_failed" });
}
// Stable, tenant-scoped, no-PII device pseudonym — the enforcement handle.
const deviceId = verdict.device.ueid;
res.json({ ok: true, deviceId });
});What you get → a verdict with device.verdict, device.ueid, device.earStatus, and acr. Why → a failing device returns a verdict (not an exception), so you decide the response; only protocol/auth/quota problems throw.
Test it, then go deeper
Drive the whole chain — extension → host → TPM → your backend → verdict — against a real TPM at /try. That is where “it works” is anchored while the client packages finish publishing.