Hardware-backed evidence

Bind evidence to hardware so that a verifierThe component that runs elsewhere, typically in CI: it sends a nonce, obtains references itself and compares. does not have to trust the attesterThe component on the machine being checked. It collects facts and reports them; it never decides the result. alone: TPMTrusted Platform Module: a chip with keys that cannot be exported and registers (PCRs) that can be extended but never set. 2.0 quotes signed by an attestation key, enrollment of that key through the TPM's endorsement key certificate, the Linux IMAIntegrity Measurement Architecture: the Linux kernel hashes files as they are used, logs each hash and extends PCR 10. log replayed to the quoted PCRPlatform Configuration Register: a TPM register that can only be extended with a hash. Firmware, bootloader and kernel extend PCRs with what they load. 10, and AMD SEV-SNPAMD Secure Encrypted Virtualization with Secure Nested Paging: confidential VMs whose launch measurement the AMD chip signs. and Intel TDXIntel Trust Domain Extensions: confidential VMs whose measurement is signed through Intel's quoting enclave. confidential VM reports. Every statement signs the verifier's nonceA random value the verifier chooses for one request, so that an old answer cannot be replayed. together with the evidence digest.

Binding, in short#

The attester computes evidenceDigest over the evidence (see SPEC.md), then asks the hardware to sign a value derived from the verifier's nonce and that digest. Both are hex strings; the hash is taken over their raw bytes, not over the hex text.

StatementBound valueWhere it goes
TPM 2.0 quoteQualifying data SHA-256(nonce ‖ evidenceDigest), 32 bytestpm.quote
AMD SEV-SNP report, Intel TDX quoteReport data SHA-512(nonce ‖ evidenceDigest), 64 bytesconfidential
IMA logNot signed itself; authenticated by replay to the quoted PCR 10ima.log

evidenceDigest leaves out tpm, confidential and ima, because they are added after the digest is computed.

A verifier checks hardware statements only with keys it pinned, or with certificate chains that end at vendor roots it ships. It never uses a key taken from the evidence.

TPM 2.0#

A TPM is a separate chip with keys that cannot be exported and platform configuration registers (PCRs) that can be extended but never set. Firmware, bootloader and kernel extend PCRs 0 to 7 with what they load; IMA extends PCR 10.

A quote is a TPM signature over selected PCR values and qualifying data chosen by the caller. A verified quote proves that a key held by the TPM signed those PCR values together with your nonce, after you created the nonce. It proves nothing about the machine unless the attestation key is trusted and the PCR values are compared with known-good values or an event log.

The Tpm class drives tpm2-tools on the attester. Tpm.verifyQuote() runs in plain Node.js, so the verifier needs no TPM software.

Enrollment#

Enrollment happens once per machine. It gives the verifier an attestation key (AKAttestation key: a key held by the TPM that signs quotes. The verifier pins it at enrollment.) to pin.

  1. The attester creates an AK under the endorsement key (EKEndorsement key: the TPM's identity key, certified by its manufacturer.) and makes it persistent (default handle 0x81010002).
  2. The attester sends the EK public area, the EK certificate (when the manufacturer stored one), and the AK public area.
  3. The verifier checks that the EK certificate chains to a TPM manufacturer CA it trusts and certifies this EK.
  4. The verifier checks that the AK is a restricted signing key created in the TPM that cannot leave it (fixedTPM, fixedParent, sensitiveDataOrigin, restricted, sign, not decrypt).
  5. The verifier runs MakeCredential: it encrypts a random secret to the EK, bound to the AK's name. Only that TPM, holding that AK, can recover it with ActivateCredential.
  6. The attester returns the secret. When it matches, the verifier pins the AK public key.

Without steps 3 to 6, pinning the AK is trust on first use: the verifier knows it talks to the same key later, not that the key lives in a real TPM.

Attestium ships no TPM manufacturer certificates. Download the CA certificates of the manufacturers (or cloud providers) you accept and pass them as roots. Intermediate CAs go in intermediates, or in roots if you accept them as trust anchors.

JavaScript
const crypto = require('node:crypto');
const fs = require('node:fs');
const {Tpm, tpmIdentity} = require('attestium');

(async () => {
  // Attester. Without a tcti, the TPM at /dev/tpmrm0 is used.
  const tpm = new Tpm({tcti: process.env.TPM2TOOLS_TCTI});
  const endorsement = await tpm.getEndorsement(); // RSA EK: {publicArea, certificate} in base64
  const ak = await tpm.getAttestationKey().catch(() => tpm.createAttestationKey());
  const akPublicArea = await tpm.getAttestationKeyPublicArea();

  // Verifier: the EK certificate chains to a manufacturer CA you trust.
  const ek = tpmIdentity.parseTpmPublic(Buffer.from(endorsement.publicArea, 'base64'));
  const roots = fs.readdirSync('tpm-roots').map(name => new crypto.X509Certificate(fs.readFileSync(`tpm-roots/${name}`)));
  const {chain} = tpmIdentity.verifyEkCertificate({certificate: Buffer.from(endorsement.certificate, 'base64'), ekKey: ek.key, roots});

  // Verifier: the AK is a restricted signing key bound to the TPM, and the one the attester reports.
  const akArea = tpmIdentity.parseTpmPublic(Buffer.from(akPublicArea, 'base64'));
  const problems = tpmIdentity.attestationKeyProblems(akArea);
  const spki = key => (typeof key === 'string' ? crypto.createPublicKey(key) : key).export({type: 'spki', format: 'der'});
  const sameKey = spki(akArea.key).equals(spki(ak.publicKey));

  // Verifier: a secret only this TPM can recover, and only for this AK.
  const secret = crypto.randomBytes(32);
  const credential = tpmIdentity.makeCredential({ek, akName: akArea.name, secret});

  // Attester: recover it.
  const answer = await tpm.activateCredential({credential: credential.toString('base64')});

  // Verifier: pin ak.publicKey when everything holds.
  const activated = crypto.timingSafeEqual(Buffer.from(answer, 'base64'), secret);
  console.log({chain, problems, sameKey, activated});
})();

verifyEkCertificate() throws when the certificate is for another key, when it does not chain to one of roots, or when a CA certificate in the chain is not currently valid. An intermediate counts only when it is a CA (basicConstraints cA) and, when it has a keyUsage extension, may sign certificates (keyCertSign). It does not check the EK certificate's own validity period, because many EK certificates carry none that is usable.

makeCredential() supports the default EK templates: RSA 2048 and ECC NIST P-256, with SHA-256 and AES-128-CFB. Pass {algorithm: 'ecc'} to getEndorsement() and activateCredential() for an ECC EK.

Quote and verify#

The quote's nonce is the qualifying data, at most 32 bytes. Attestium binds SHA-256(nonce ‖ evidenceDigest), which is exactly 32 bytes.

JavaScript
const {Tpm, util} = require('attestium');

(async () => {
  const tpm = new Tpm({tcti: process.env.TPM2TOOLS_TCTI});

  // Enrollment (once): the verifier pins the AK and the known-good boot PCRs.
  const ak = await tpm.getAttestationKey().catch(() => tpm.createAttestationKey());
  const expectedPcrs = {sha256: await tpm.readPcrs([0, 1, 2, 3, 4, 5, 6, 7], 'sha256')};

  // Each audit: the verifier's nonce, and the attester's evidence digest.
  const nonce = util.generateNonce();
  const evidenceDigest = util.sha256('stands for evidence.evidenceDigest(document)');
  const qualifyingData = util.sha256(Buffer.concat([Buffer.from(nonce, 'hex'), Buffer.from(evidenceDigest, 'hex')]));

  // Attester.
  const quote = await tpm.quote({nonce: qualifyingData, pcrs: [0, 1, 2, 3, 4, 5, 6, 7, 10]});

  // Verifier: recompute the qualifying data from its own nonce and the digest it recomputed.
  const result = Tpm.verifyQuote({quote, publicKey: ak.publicKey, nonce: qualifyingData, expectedPcrs});
  console.log(result.valid, result.errors, result.attest.clockInfo);
})();

Tpm.verifyQuote() returns {valid, errors, attest, pcrs}. It checks:

  • the signature, with the pinned AK public key (RSASSA-PKCS1-v1_5 or ECDSA; SHA-1 is refused);
  • that the signed structure is a TPMS_ATTEST quote generated by the TPM;
  • that its extra data equals the expected qualifying data (a stale or replayed quote fails);
  • that the reported PCR values hash to the signed PCR digest, and that no value is reported without being quoted;
  • each PCR in expectedPcrs.

attest.clockInfo holds the TPM's resetCount and restartCount; a change between audits means the machine rebooted or resumed.

Which PCRs to pin depends on the platform. PCRs 0 to 7 cover firmware and boot; their values change with firmware and kernel updates, so update the pinned values when you update the machine. Quote PCR 10 when you use IMA, but do not pin its value: it grows with every measurement.

IMA#

The Linux Integrity Measurement Architecture makes the kernel hash files as they are executed, mapped executable, or read (depending on the policy), append each measurement to a log, and extend PCR 10 before the file is used. Entries cannot be removed from a PCR. So when the log replays to the PCR 10 value in a verified quote, the logged hashes are the files the kernel loaded, even if the machine's root user is hostile.

The attester reads the log after the quote: ima.readLog() reads /sys/kernel/security/ima/binary_runtime_measurements (root or CAP_DAC_READ_SEARCH needed), and the evidence carries it base64 encoded in ima.log.

The log keeps growing after the quote. The verifier replays it entry by entry until the running value equals the quoted one. The entries up to that point are backed by the TPM; later ones are not.

JavaScript
const {ima} = require('attestium');

/**
 * @param {Object} quote - verified with Tpm.verifyQuote, covering sha256 PCR 10
 * @param {string} log - the evidence's ima.log (base64)
 * @returns {Map<string, {algorithm: string, hash: string, count: number, hashes: string[]}>}
 */
function imaMeasurements(quote, log) {
  const entries = ima.parseBinaryLog(Buffer.from(log, 'base64'));
  const backed = ima.backedEntries(entries, quote.pcrs.sha256['10'], 'sha256');
  if (!backed) {
    throw new Error('The IMA log does not replay to the quoted PCR 10');
  }

  // Path -> the measured hash (the latest), and every hash seen for it.
  return ima.measurementsByPath(backed);
}

module.exports = {imaMeasurements};

Compare each running file's hash in the evidence with its IMA measurement. A file that IMA measured with another hash contradicts the attester's report.

Supported: the binary log with the ima-ng, ima-sig and ima-buf templates, replayed into the SHA-256 or SHA-1 bank; the legacy ima template only into SHA-1. Violation entries (a file opened for writing while measured) extend the PCR with all-ones and are skipped by measurementsByPath(), as are ima-buf entries, which measure buffers (keys, the kexec command line) under a name that is not a file's. What is measured depends entirely on the IMA policy in force.

Confidential VMs#

In a confidential VM, the CPU measures the VM's initial memory at launch and signs a report that includes 64 bytes chosen by the guest. The host operator can neither read the guest's memory nor forge the report. The attester asks the kernel for a report through configfs-tsm (/sys/kernel/config/tsm/report, Linux 6.7 and later).

JavaScript
const {confidential} = require('attestium');

/**
 * Attester: the confidential field of the evidence, over (nonce, digest).
 * Needs a confidential VM with configfs-tsm, and root (or a report entry
 * directory prepared by root, passed as {entry}).
 */
function confidentialEvidence(nonce, evidenceDigest) {
  try {
    const result = confidential.collectReport(confidential.reportData(nonce, evidenceDigest));
    return {
      available: true,
      provider: result.provider,
      report: result.report.toString('base64'),
      auxblob: result.auxblob ? result.auxblob.toString('base64') : null,
    };
  } catch (error) {
    return {available: true, error: error.code || error.message, required: false};
  }
}

/**
 * Verifier: check the report and compare its launch measurement.
 */
function verifyConfidentialEvidence(document, nonce, expectedMeasurement) {
  const result = confidential.verifyConfidential(document.confidential, confidential.reportData(nonce, document.evidenceDigest));
  if (result.measurement !== expectedMeasurement) {
    throw new Error(`Unexpected launch measurement ${result.measurement}`);
  }

  return result;
}

module.exports = {confidentialEvidence, verifyConfidentialEvidence};

verifyConfidential() throws on any failure and returns {type, measurement, ...} on success. The verifier must still compare measurement with the value it expects for the VM image it deployed.

AMD SEV-SNP#

Provider sev_guest. The report is signed by the chip's VCEKVersioned Chip Endorsement Key: the AMD per-chip key that signs SEV-SNP reports, certified through the ASK to AMD's root., or by a VLEK (a key AMD issues to a cloud provider, which loads it into its chips; AWS uses them). The report says which. Attestium checks:

  • the report data equals SHA-512(nonce ‖ evidenceDigest);
  • a VCEK certificate chains through the ASK, a VLEK certificate through the ASVK, to AMD's ARK for Milan, Genoa or Turin. The ARK, ASK and ASVK certificates ship in lib/data/vendor-roots.js, with their SHA-256 fingerprints so you can compare them with AMD's published values. An intermediate in the host's certificate table (its ASK entry holds the ASVK on VLEK hosts) is used instead when the ARK signed it, it is a CA, and it is not the other kind's;
  • the TCB values in the certificate equal the report's reported TCB, and, for a VCEK, the certificate's chip id equals the report's;
  • the ECDSA P-384 signature over the report;
  • the guest policy does not allow debugging, nor a migration agent (which the host chooses at launch, the report does not name, and which can export the VM's memory). Pass {allowMigrationAgent: true} to accept one.

The VCEK or VLEK certificate comes from the host (the auxblob certificate table) when the host provides it. Otherwise pass it as {vcek} (either kind). A VCEK can be fetched from AMD's key distribution service; a VLEK certificate only from the cloud provider:

JavaScript
const {confidential, http} = require('attestium');

async function fetchVcek(reportBase64) {
  const parsed = confidential.parseSnpReport(Buffer.from(reportBase64, 'base64'));
  // Reports of version 3 and later name their product; try the older ones otherwise.
  const product = confidential.snpProduct(parsed) || 'Milan';
  return http.httpGet(confidential.vcekUrl(parsed, product));
}

module.exports = {fetchVcek};

The result has type: 'sev-snp', measurement (hex), product, key ('vcek' or 'vlek'), tcb, policy and vmpl. AMD's certificate revocation lists are not checked, and the TCB is reported, not compared with a minimum: compare tcb and vmpl with your own policy.

Intel TDX#

Provider tdx_guest. The quote (version 4 or 5) is signed by the quoting enclave's attestation key. Attestium checks:

  • the TD report data equals SHA-512(nonce ‖ evidenceDigest);
  • the PCK certificate chain in the quote ends at Intel's SGX root CA (shipped) and verifies link by link;
  • the quoting enclave report is signed by the PCK and binds the attestation key;
  • the quote signature;
  • the TD is not in debug mode.

The result has type: 'tdx', measurement (MRTD), rtmr, teeTcbSvn, mrConfigId and mrOwner. The platform's TCB level (Intel's TCB info) is reported, not evaluated, and Intel's PCK certificate revocation lists are not checked: compare teeTcbSvn with your own policy.

What hardware does not cover#

  • A TPM quote proves which machine and which boot state. It does not prove that the attester reported files and processes honestly after boot. IMA narrows that gap for the files its policy measures.
  • A confidential VM report proves the launch measurement. It says nothing about what the guest loaded after launch unless the measured image enforces it.
  • Firmware and kernel bugs, and physical attacks on the TPM bus, are outside the model.

See Security for the full threat model.

Edit this page on GitHub