Skip to content
Guides · Guarantee only a device can decrypt

Guarantee only a Browser device can decrypt

You'll mint a key once, keep its public half on your server, and from then on encrypt to it whatever only that device may read. The installation needs to be enrolled first.

1

Ask for a key challenge

On your server, use issueKeyChallenge with purpose: "decrypt" and the device ID you expect, and send the keyChallenge string to the client with its nonce.

server (Node)ts
app.post("/key/challenge", async (req, res) => {
  const deviceId = await deviceOf(accountOf(req));

  const { nonce, keyChallenge } = await rh.issueKeyChallenge({
    purpose: "decrypt",
    expectedDevices: [deviceId],
  });

  res.json({ nonce, keyChallenge });
});

In a browser, keys go through a browser extension you build: the page opens an envelope on every use, and a link handler would open your app each time.

2

Mint the key

In the page, send the key challenge to your extension as a mint message. Your app, the extension's native messaging host, answers it with RootHeraldMintKey, the attestation key blob and a buffer for the new key, keeps the key blob itself, and returns the certification. Post the certification and its nonce to your server.

page.tsts
const { nonce, keyChallenge } = await postJSON("/key/challenge", {});

const { certification } = await chrome.runtime.sendMessage(EXTENSION_ID, {
  type: "mint",
  keyChallenge,
});

await postJSON("/key/certify", { nonce, certification });
3

Keep the key

On your server, pass the certification and nonce to certifyKey. It returns the key: keep the whole result with the account that deviceId belongs to, since encryptToDevice reads its alg and format as well as the jwk.

server (Node)ts
app.post("/key/certify", async (req, res) => {
  const { nonce, certification } = req.body;

  const key = await rh.certifyKey(nonce, certification);

  await saveDecryptKey(accountOf(req), key.deviceId, key);

  res.sendStatus(204);
});
4

Encrypt to the key

On your server, use encryptToDevice with the key you kept and the bytes only this device may read. It runs locally and returns the JWE string; send that to the page.

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

app.post("/session", async (req, res) => {
  const key = await decryptKeyOf(accountOf(req));

  const token = await issueSessionToken(accountOf(req));

  res.json({ envelope: encryptToDevice(key, token) });
});
5

Open it on the device

In the page, send the envelope to your extension as a decrypt message and use the plaintext it returns. Your app loads the blob with RootHeraldLoadKey and opens the envelope with RootHeraldDecrypt: no network, no challenge, no prompt.

page.tsts
const { envelope } = await postJSON("/session", {});

const { plaintext } = await chrome.runtime.sendMessage(EXTENSION_ID, {
  type: "decrypt",
  envelope,
});

useSessionToken(plaintext);

If the TPM has been cleared, every blob is gone, the attestation key's included: your app gets RH_ERR_KEY_UNLOADABLE. Have it enroll again, then mint a new key. The device ID stays the same; the key ID is new.

Coming soon

Root Herald bridges are in development and coming soon: an extension and host your pages will be able to use directly, instead of building your own.