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.
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
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
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 NULLRun 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.
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
| Platform | identity | posture | key | Notes |
|---|---|---|---|---|
| Windows | Served | Served | Served | Wrapped blob, raw r||s, verified locally. Enrol needs one UAC; respond and sign never do. |
| Linux | Served | Served | Served | Identical to Windows via tpm2-tss. No elevation given /dev/tpmrm0. |
| macOS | Served | Refused | Refused | The Secure Enclave key is unattested, so there is no posture to collect and no certified app key. |
| iOS | Served | Refused | Served | The 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.
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.