Skip to content
Guide · Tutorial

Device-bound keys

A challenge carries an ask: what the device is being asked to prove. This tutorial builds the three things that unlocks, in order of cost. Bind an account to the chip it signed up on with an identity ask. Step up on boot posture before a sensitive action with a posture ask. And mint a signing key that cannot leave the TPM with a key ask, so every later request is verified in your own backend with your standard library and no Root Herald call. Each part is runnable on its own; the last section is the honest per-platform matrix.

One shape, three asks

Nothing about the integration changes between the three. Your backend mints a challenge with rh.issueChallenge({ ask, policy }), relays the challenge string to the client verbatim, the client answers it with respond(challenge) (browser) or RootHeraldRespond (native), and your backend appraises the evidence with rh.verify(evidence, { challengeId }). The ask decides what the device collects and what the verdict carries; the policy named on the challenge decides how it is judged, and verify can tighten it but never loosen it (422 policy_downgrade). Omit the ask and you get identity + posture — what every verify collected before asks existed.

0. Set up the server client

rh.tsts
import { RootHeraldClient } from "@rootherald/node";

export const rh = new RootHeraldClient({ secretKey: process.env.RH_SECRET_KEY! }); // rh_sk_…

Enrollment is a prerequisite everywhere below (a device must have an attestation key before it can answer anything). The first verify on a new device comes back enrollmentRequired; relay the two enrol legs with rh.relayEnroll(blob, challengeId) / rh.relayActivate(blob) as in the quickstart, then retry the same verify. Passing the challenge id runs admission under the challenge's policy, which matters in part 4.

1. Device binding: an identity ask and a bound device id

The cheapest ask. The enrolled TPM signs a fresh nonce over PCR 7 alone — no event log, no boot claims — which is enough to prove “this is the same chip as last time”. Record device.ueid when the account is created; on every login, refuse a session presented from a different chip. A stolen cookie or an exported session token fails on the attacker's machine because that machine cannot produce this chip's quote.

server: bind at signupts
// Challenge: identity only. Any policy works; strict-hardware keeps VMs out.
app.post("/api/challenge", async (_req, res) => {
  const { challengeId, challenge } = await rh.issueChallenge({ ask: ["identity"] });
  res.json({ challengeId, challenge });           // relay `challenge` verbatim
});

app.post("/api/signup", async (req, res) => {
  const { challengeId, evidence, email } = req.body;
  const result = await rh.verify(evidence, { challengeId });
  if (result.enrollmentRequired) return res.status(409).json({ error: "enrollment_required" });
  if (result.device.verdict !== "pass") return res.status(403).json({ error: "device_rejected" });

  // The bound device id. Stable per tenant, no PII, survives OS reinstall.
  const user = await users.create({ email, boundDeviceId: result.device.ueid });
  res.json({ ok: true, userId: user.id });
});
server: enforce at logints
app.post("/api/login", async (req, res) => {
  const { challengeId, evidence, email, password } = req.body;
  const user = await users.authenticate(email, password);
  if (!user) return res.status(401).end();

  const result = await rh.verify(evidence, { challengeId });
  if (result.device.verdict !== "pass" || result.device.ueid !== user.boundDeviceId) {
    // Right password, wrong chip. Route to your "new device" flow, not to a session.
    return res.status(403).json({ error: "device_not_bound" });
  }
  res.json({ session: await sessions.issue(user.id, result.device.ueid) });
});
browser: answer the challengets
import { respond } from "@rootherald/browser";

const { challengeId, challenge } = await fetch("/api/challenge", { method: "POST" }).then((r) => r.json());
const { evidence } = await respond(challenge);   // identity: a quote over PCR 7, no prompt on a known device
await fetch("/api/login", { method: "POST", body: JSON.stringify({ challengeId, evidence, email, password }) });
Why identity and not the default

Identity + posture collects and replays the full event log on every call. A login does not need to know how the machine booted, only which machine it is, and the identity ask costs the device one small quote instead of a log replay. Save posture for the moments that need it.

2. Step-up: a posture ask before a sensitive action

Before a transfer, a password change, or an admin action, ask the device to prove how it booted. A posture ask collects the full PCR set, the event log, and the Secure Boot claims; judged under strict-hardware it fails any VM, emulator, or real chip with Secure Boot off. Require the secure-boot assurance claim explicitly so the gate reads as what it is.

server: step-up challengets
app.post("/api/stepup/challenge", async (_req, res) => {
  const { challengeId, challenge } = await rh.issueChallenge({
    ask: ["posture"],
    policy: "rootherald:builtin:strict-hardware",   // fixed here; verify cannot loosen it
  });
  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" });   // part 1 still applies
  }
  // The verdict is fresh (result.verdict.expiresAt, ~5 min). Act now; do not cache it.
  await ledger.transfer(req.session.userId, to, amount);
  res.json({ ok: true });
});

The booleans on result.devicesecureBootVerified, eventLogVerified — are computed server-side from an event log that had to reproduce the signed PCRs, so a client cannot set them. The claim in assuranceClaimsMet is the same fact in the form your policy logic wants: required ⊆ met ⇒ allow. Every field is on the verdict reference.

3. Trusted channel: a key ask, then sign every request

The key ask does everything posture does and then has the device mint an ECC P-256 key under the TPM's storage parent. The attestation key certifies it (TPM2_Certify) over the same nonce, so the verdict can vouch that the key is inside this chip. Two things come out of one challenge: the client gets the wrapped private half — a blob that is useless off this TPM — and, on a passing verdict, your backend gets the public JWK. Root Herald keeps neither. From then on the device signs every request and you verify each one with your standard library. No nonce, no round trip, no Root Herald on the hot path.

server: bind the key oncets
app.post("/api/device/challenge", async (_req, res) => {
  const { challengeId, challenge } = await rh.issueChallenge({
    ask: ["key"],                                   // implies posture; full evidence is collected
    policy: "rootherald:builtin:strict-hardware",
    keyPurpose: "sign",
  });
  res.json({ challengeId, challenge });
});

app.post("/api/device/bind", async (req, res) => {
  const { challengeId, evidence } = req.body;
  const result = await rh.verify(evidence, { challengeId });
  if (result.device.verdict !== "pass" || !result.key) {
    return res.status(403).json({ error: "device_rejected" });   // no pass, no key
  }
  // Store the PUBLIC half against the account. The blob never reaches you.
  await deviceKeys.put(req.session.userId, {
    keyId: result.key.keyId,
    jwk: result.key.jwk,                                         // { kty: "EC", crv: "P-256", x, y }
    deviceId: result.device.ueid,
    certifiedAt: result.key.certifiedAt,
  });
  res.json({ ok: true, keyId: result.key.keyId });
});
browser: keep the blob, sign every requestts
import { respond, sign, KeyUnloadableError } from "@rootherald/browser";

// Bind once. keyBlob is the TPM-wrapped private half: store it like any other
// per-device state (IndexedDB). It is not a secret; it is useless off this chip.
const { challengeId, challenge } = await fetch("/api/device/challenge", { method: "POST" }).then((r) => r.json());
const { evidence, keyBlob } = await respond(challenge);
await idb.put("rh.keyBlob", keyBlob);
await fetch("/api/device/bind", { method: "POST", body: JSON.stringify({ challengeId, evidence }) });

// Every request after that: sign the body. No nonce, no network, no prompt.
async function signedFetch(url: string, body: string) {
  const blob = await idb.get("rh.keyBlob");
  let signature: Uint8Array;
  try {
    signature = await sign(blob, new TextEncoder().encode(body));   // raw r||s, 64 bytes
  } catch (err) {
    if (err instanceof KeyUnloadableError) {
      await idb.delete("rh.keyBlob");   // TPM cleared: the key is gone. Bind again.
      return rebind();
    }
    throw err;
  }
  return fetch(url, {
    method: "POST",
    body,
    headers: { "content-type": "application/json", "x-rh-signature": toBase64(signature) },
  });
}
server: verify locally, no Root Herald callts
import { RootHeraldClient } from "@rootherald/node";

app.post("/api/orders", async (req, res) => {
  const { jwk } = await deviceKeys.get(req.session.userId);
  const signature = Buffer.from(req.header("x-rh-signature") ?? "", "base64");

  // ECDSA P-256 over SHA-256 with Node's crypto. Raw r||s or DER both verify.
  if (!rh.verifyKeySignature(jwk, req.rawBody, signature)) {
    return res.status(401).json({ error: "bad_signature" });
  }
  res.json(await orders.create(req.session.userId, req.body));
});
What this replaces, and what it doesn't

A signed request proves the body came from the chip that minted the key, with no freshness window to manage. It does not prove how the machine booted today — that is a posture ask, part 2 — and it does not stop replay of an identical body unless you put a counter or timestamp in what you sign. Put one in. Re-run a key challenge on whatever cadence your policy wants, passing the existing blob to respond(challenge, { key: blob }) to re-certify it under today's policy rather than mint a new one.

Standard library only, by design

verifyKeySignature in every server SDK is a thin wrapper over the platform's own ECDSA — Node crypto, Go crypto/ecdsa, Java java.security, .NET System.Security.Cryptography, PHP and Ruby openssl. If you would rather not have the SDK on the hot path at all, import the JWK and call that directly; there is nothing Root Herald-specific in the signature.

4. OEM-only fleet: refuse the wrong devices before they enrol

A fleet policy says which machines may join at all, and the right time to say no is before the device spends its one elevation prompt on an enrolment that would fail at verify. Create the policy in the dashboard (or via the admin API), name it on the challenge, and relay the enrol leg with that challenge id: admission runs under the policy, and a class it refuses — a cloud vTPM, a software emulator — comes back 422 admission_refused on the first leg, with no UAC shown. At verify, require the oem-keyed claim so a real chip with a custom Secure Boot key set is refused too.

admin API: the fleet policyhttp
POST /api/v1/admin/policies
{
  "name": "oem-fleet",
  "acceptedClassGroups": ["hardware", "firmware-tpm"],   // no cloud-vtpm, no emulated
  "requireEventLog": true,
  "requireSignedQuote": true,
  "minAcr": "urn:rootherald:device:high"
}
// 201 — keep the id. Set it as your project default in the dashboard, or name it
// on every challenge as below.
server: challenge + enrol relay under the fleet policyts
import { AdmissionRefusedError } from "@rootherald/node";

app.post("/api/fleet/challenge", async (_req, res) => {
  const { challengeId, challenge } = await rh.issueChallenge({
    ask: ["posture"],
    policy: process.env.RH_FLEET_POLICY_ID,        // the custom policy's id
  });
  res.json({ challengeId, challenge });
});

// First contact: the client's EnrollBegin blob, plus the challenge it was answering.
app.post("/api/fleet/enroll", async (req, res) => {
  const { enrollRequestBlob, challengeId } = req.body;
  try {
    const enroll = await rh.relayEnroll(enrollRequestBlob, challengeId);
    return res.json({ challenge: enroll.challenge, done: enroll.alreadyEnrolled });
  } catch (err) {
    if (err instanceof AdmissionRefusedError) {
      // Not a hardware-class device under the fleet policy. Nothing to retry.
      return res.status(403).json({ error: "device_not_eligible" });
    }
    throw err;
  }
});

app.post("/api/fleet/verify", async (req, res) => {
  const { challengeId, evidence } = req.body;
  const result = await rh.verify(evidence, { challengeId });
  const oemKeyed = result.assuranceClaimsMet.includes("rootherald:assurance:oem-keyed");
  if (result.device.verdict !== "pass" || !oemKeyed) {
    return res.status(403).json({ error: "not_oem_keyed" });
  }
  res.json({ ok: true, deviceId: result.device.ueid });
});

The native client sees the refusal as an empty relay result on leg one and never calls EnrollComplete; the browser SDK throws AdmissionRefusedError from enroll(). Either way the user sees no prompt. How the class is derived is on the TPM-class taxonomy page; what oem-keyed means is on the acceptance-policy page.

5. What each platform can serve

The asks are the same everywhere; the hardware is not. This is the honest matrix. A challenge that asks for something a platform cannot serve is refused on the device (RH_ERR_ASK_UNSUPPORTED / AskUnsupportedError) before anything touches the network; the fix is to mint a smaller ask, and RootHeraldPreCheck's app_keys_supported tells you in advance.

PlatformidentityposturekeyWhat that means for you
WindowsServedServedServedAll three. The app key is a TPM key under the storage parent; the client keeps the wrapped blob, signatures are raw r||s, verified locally. Enrol needs one UAC; respond and sign never do.
LinuxServedServedServedAll three, identically to Windows via tpm2-tss. No elevation anywhere given access to /dev/tpmrm0.
macOSServedRefusedRefusedIdentity only. The Secure Enclave key is unattested — macOS cannot prove its hardware to a third party today — so there is no posture to collect and no certified app key to hand out. Part 1 works; parts 2 and 3 do not. Read the cross-platform guide before promising macOS anything.
iOSServedRefusedServedIdentity and key, with a difference that matters: the App Attest key is the app key. No blob comes back to the client; the verdict's key block carries that key's JWK. Later signatures are App Attest assertions — CBOR with authenticatorData and a DER signature — not raw r||s. verifyKeySignature does not parse assertions in this release, so an iOS-signed request is verified by sending the assertion through Root Herald's verify, not locally. Local verification of iOS assertions is a later helper; do not build a Root-Herald-free hot path on iOS yet. No posture: a phone has no PCRs or boot log to prove.
Write policy against claims, not platforms

Part 2's secure-boot requirement simply is not met on macOS or iOS, and part 3's local verification simply is not available there. Decide up front whether those platforms get the feature, a weaker version, or a different path — and express it as which claims you require, so the decision is in one place.