Skip to content
Integrate · Native / embeddedIn development

Verify a device from a native app

The full client + server flow for a desktop app, game launcher, or embedded agent: your backend issues a challenge, your binary collects a fresh TPM quote through the native SDK, and your backend appraises the evidence. The static library is keyless — your rh_sk_ secret never ships to the device.

What ships today

The server half — @rootherald/node — is available on npm. The client half, sdk-native, is in development and Windows-only today (the macOS Secure Enclave and Linux tpm2-tss collectors are declared in the ABI but return ROOTHERALD_ERR_INTERNAL until they land). It links as a static library and is keyless — it holds no Root Herald key and opens no socket to Root Herald. You can drive the equivalent chain end to end today at /try.

1

[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 on your backend.

terminal (your backend)bash
npm install @rootherald/node

What you get → the RootHerald client that issues challenges and appraises evidence. Why → the secret and the verdict live only on your server; the native binary you ship to users is untrusted and keyless.

server setup (Node)ts
import { RootHerald } from "@rootherald/node";

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

[server] Issue a challenge and relay the nonce

What to do → mint a single-use challenge and send the nonce down your own client↔backend channel to the native app.

api/challenge (Node)ts
app.post("/api/challenge", async (_req, res) => {
  const { challengeId, nonce } = await rh.issueChallenge();
  res.json({ challengeId, nonce });
});

What you get → { challengeId, nonce }. Why → the TPM quote is taken over nonce, so freshness and anti-replay are bound inside the signature.

3

[client] Collect evidence with the native SDK

What to do → link RootHerald.lib (Windows) into your binary and call the C ABI: create a client, collect over the relayed nonce, then free the buffer.

collect.c (sdk-native C ABI)c
#include "rootherald.h"

// nonce_b64 came from YOUR backend's /api/challenge (relayed to this process).
char* collect_evidence(const char* nonce_b64) {
  RootHeraldClient* client = RootHeraldClient_Create(); // keyless: no key, no endpoint
  if (!client) return NULL;

  char* evidence_json = NULL;
  RootHeraldStatus rc =
      RootHeraldClient_CollectEvidence(nonce_b64, &evidence_json);

  if (rc == ROOTHERALD_ERR_NOT_ENROLLED) {
    // First run on this device — bootstrap the key first (see note below),
    // then retry CollectEvidence.
  }

  RootHeraldClient_Destroy(client);
  // Caller owns evidence_json; send it, then:
  //   RootHeraldClient_FreeEvidence(evidence_json);
  return (rc == ROOTHERALD_OK) ? evidence_json : NULL;
}
First-time enrollment (once per device)

If CollectEvidence returns ROOTHERALD_ERR_NOT_ENROLLED, bootstrap the device key with the keyless two-leg handshake: RootHeraldClient_EnrollBegin emits the /devices/enroll request body, your backend relays it (via @rootherald/node) and hands back the challenge, then RootHeraldClient_EnrollComplete emits the /devices/activate body. Both legs run under a single elevation and never open a Root Herald socket. Full API in the Native C++ SDK reference.

What you get → a newly-allocated, opaque evidence_json blob you own and must free with RootHeraldClient_FreeEvidence. Why → the blob is exactly what /attestations/verify expects; the client forwards it verbatim and never inspects a verdict.

4

[wire] Send the blob to your backend

What to do → ship { challengeId, evidence }from the native app to your backend over your own transport (HTTPS, your game's existing session channel, whatever you already use). Why → only your backend holds rh_sk_; the client's job ends at handing over the blob.

5

[server] Appraise the evidence and act

What to do → submit the blob and branch on the verdict — identical to every other client track.

api/verify (Node)ts
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;
  }

  if (verdict.device.verdict !== "pass") {
    return res.status(403).json({ error: "device_check_failed" });
  }
  res.json({ ok: true, deviceId: verdict.device.ueid });
});

What you get → a verdict with device.verdict, device.ueid, device.earStatus, and acr. Why → the enforcement decision is yours, made server-side where the key and the trust anchors live.

A worked example, and building your own collector

For a native collector living inside a game launcher — the canonical embedded case — the embedded game SDK guide walks a launcher that gates match entry on a device verdict. If you need a collector on a platform the SDK doesn't cover yet, or your own native messaging host, the Native C++ SDK reference documents the complete C ABI (enroll, collect, posture, lifecycle) you build against.