Skip to content
Guides

App keys

One key ask mints an ECC P-256 key under the TPM's storage parent, certified by the attestation key. The client keeps a wrapped blob; you keep the JWK. Root Herald keeps neither.

server (Node) — mint once, verify foreverts
import { RootHeraldClient, verifyKeySignature } from "@rootherald/node";
const rh = new RootHeraldClient({ secretKey: process.env.RH_SECRET_KEY! });

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).end();   // no pass, no key
  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,
  });
  res.json({ keyId: result.key.keyId });
});

// Every later request: verified here, offline. ECDSA P-256 over SHA-256; raw r||s or DER.
app.post("/api/orders", async (req, res) => {
  const { jwk } = await deviceKeys.get(req.session.userId);
  if (!verifyKeySignature(jwk, req.rawBody, req.header("x-rh-signature") ?? "")) {
    return res.status(401).json({ error: "bad_signature" });
  }
  res.json(await orders.create(req.session.userId, req.body));
});

Browser: keep the blob, sign every request

page.tsts
import { respond, sign, KeyUnloadableError } from "@rootherald/browser";

// Bind once. `key` is the TPM-wrapped private half: not a secret, useless off this chip.
const { challengeId, challenge } = await post("/api/device/challenge", {});
const { evidence, key } = await respond(challenge);
await idb.put("rh.key", key);
await post("/api/device/bind", { challengeId, evidence });

// Sign the body. No nonce, no network, no prompt.
async function signedFetch(url: string, body: string) {
  const key = await idb.get("rh.key");
  let signature;
  try {
    ({ signature } = await sign(key, body));     // base64url ES256, r||s or DER
  } catch (err) {
    if (err instanceof KeyUnloadableError) { await idb.delete("rh.key"); return rebind(); }   // TPM cleared
    throw err;
  }
  return fetch(url, { method: "POST", body, headers: { "content-type": "application/json", "x-rh-signature": signature } });
}

Native: LoadKey once, Sign per request

trusted-channel.cc
char    evidence[65536]; size_t ev_len = 0;
uint8_t blob[512];       size_t blob_len = 0;                 /* 512 always suffices */
RH_STATUS st = RootHeraldRespond(h, challenge, NULL,
                                 evidence, sizeof evidence, &ev_len,
                                 blob, sizeof blob, &blob_len);
if (st == RH_ERR_ASK_UNSUPPORTED) { /* this platform cannot mint keys: ask for less */ }
save_blob(blob, blob_len);                                    /* yours; never sent to Root Herald */

RH_KEY_HANDLE key = NULL;
st = RootHeraldLoadKey(h, blob, blob_len, &key);
if (st == RH_ERR_KEY_UNLOADABLE) { /* TPM cleared: discard the blob, answer a fresh key challenge */ }

uint8_t sig[64]; size_t sig_len = 0;                          /* raw r||s for P-256 */
RootHeraldSign(key, (const uint8_t*)body, body_len, sig, sizeof sig, &sig_len);
RootHeraldCloseKey(key);

Re-certify and revoke

await respond(challenge, { key });            // browser: certify the existing key under today's policy
RootHeraldRespond(h, challenge, key,);      // C: pass the loaded key instead of NULL

Run a key challenge again on whatever cadence your policy wants; key.certifiedAt on the verdict is the key's age. To revoke, delete the JWK on your side: nothing signed with the key verifies after that, and Root Herald holds no copy to revoke. A cleared TPM surfaces as KeyUnloadableError / RH_ERR_KEY_UNLOADABLE; discard the blob and mint anew. Do not re-enrol: the attestation key is unaffected.

Put a counter or timestamp in what you sign

A signature proves the body came from the chip. It does not stop replay of an identical body, and it does not prove how the machine booted today; that is a posture ask.

Platform matrix

PlatformidentityposturekeyNotes
WindowsServedServedServedWrapped blob, raw r||s, verified locally. Enrol needs one UAC; respond and sign never do.
LinuxServedServedServedIdentical to Windows via tpm2-tss. No elevation given /dev/tpmrm0.
macOSServedRefusedRefusedThe Secure Enclave key is unattested, so there is no posture to collect and no certified app key.
iOSServedRefusedServedThe App Attest key is the app key: no blob comes back, and the verdict's key.jwk is its public half. See below.

RootHeraldPreCheck's app_keys_supported (browser: getClientStatus()) says in advance whether a key challenge will be served.

iOS signatures are assertions

client.assert(message) produces an App Attest assertion — 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. Do not build a Root-Herald-free hot path on iOS yet.