crates/nornir-tpm/src/lib.rs
//! **A pure-Rust TPM 2.0 client** — enough of the wire protocol to *seal* the
//! appliance's WireGuard private key to the platform's boot state and *unseal*
//! it again on a boot that measures the same.
//!
//! # Why this exists at all
//!
//! Until 2026-08-16 the appliance's WireGuard private key was base64 on an
//! unencrypted FAT partition, protected by `0600` and nothing else. Pull the
//! drive and you *are* that appliance to the hub — the real key, no forgery
//! needed. The design notes had always called for a TPM seal; nothing in the
//! tree had ever spoken to a TPM. This module is that missing half.
//!
//! # Why it is hand-marshalled
//!
//! Every TPM 2.0 binding in the Rust ecosystem is a wrapper around the C
//! `tss2` stack. This crate hand-rolls Authenticode, GPT, FAT, md RAID1
//! superblocks and ISO-9660 precisely so the appliance links **no C** it did
//! not write; adding `tss2` for six commands would be the largest foreign
//! surface in the whole boot path. The TPM interface is a byte protocol over a
//! character device — `write(2)` a command, `read(2)` a response — and the
//! command set below is six kilobytes of struct marshalling.
//!
//! ```text
//! command tag(u16) size(u32) code(u32) [handles] [authArea] [parameters]
//! response tag(u16) size(u32) rc(u32) [handles] [paramSize] [parameters] [authArea]
//! ```
//!
//! # The seal, in one paragraph
//!
//! `CreatePrimary` on the **owner hierarchy** derives a storage root key
//! deterministically from the TPM's own seed and the template — the same
//! template on the same chip always yields the same SRK, and *no other chip*
//! can produce it, which is what makes a stolen blob inert. A **trial**
//! session plus `PolicyPCR` computes the policy digest for the chosen PCR set
//! without authorising anything; that digest becomes the sealed object's
//! `authPolicy`. `Create` seals the secret under the SRK with that policy and
//! hands back an encrypted `outPrivate` + a plaintext `outPublic` — the only
//! two things that go to disk. To get the secret back, a **policy** session
//! runs the same `PolicyPCR` against the *live* PCRs and `Unseal` succeeds
//! only if the resulting digest equals the one baked into the object. A PCR
//! that moved is a policy digest that does not match, and the TPM refuses.
//!
//! # What this module deliberately does NOT do
//!
//! * No HMAC sessions, no parameter encryption. The secret crosses the
//! kernel/TPM boundary in the clear on a bus an attacker with that access
//! has already lost to. Salted-session encryption is a real hardening and it
//! is a separate, provable change.
//! * No persistent handles (`EvictControl`). The SRK is re-derived on every
//! call, which costs one `CreatePrimary` and owes the NV store nothing.
//! * No `Startup`/`Shutdown`. `/dev/tpmrm0` is the kernel's resource manager;
//! the chip is already started by the time anything here runs.
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
// ── the constants, verbatim from TPM 2.0 Part 2 ─────────────────────────────
/// `TPM_ST_NO_SESSIONS` — a command carrying no authorisation area.
const TPM_ST_NO_SESSIONS: u16 = 0x8001;
/// `TPM_ST_SESSIONS` — a command carrying one or more authorisation areas.
const TPM_ST_SESSIONS: u16 = 0x8002;
/// `TPM_RH_OWNER` — the storage hierarchy the SRK is derived under.
const TPM_RH_OWNER: u32 = 0x4000_0001;
/// `TPM_RH_NULL` — "no object", used for the unsalted/unbound session.
const TPM_RH_NULL: u32 = 0x4000_0007;
/// `TPM_RS_PW` — the password "session" that is not a session at all.
const TPM_RS_PW: u32 = 0x4000_0009;
const CC_CREATE_PRIMARY: u32 = 0x0000_0131;
const CC_CREATE: u32 = 0x0000_0153;
const CC_LOAD: u32 = 0x0000_0157;
/// `TPM2_Quote` — the chip SIGNS a statement about its own PCRs. The whole of
/// remote attestation is this one command plus a key the verifier already
/// knows.
const CC_QUOTE: u32 = 0x0000_0158;
/// `TPM2_NV_UndefineSpace`.
const CC_NV_UNDEFINE_SPACE: u32 = 0x0000_0122;
/// `TPM2_NV_DefineSpace`.
const CC_NV_DEFINE_SPACE: u32 = 0x0000_012A;
/// `TPM2_NV_Increment` — the monotonic step. The chip owns the "never goes
/// backwards" property; nothing above it has to.
const CC_NV_INCREMENT: u32 = 0x0000_0134;
/// `TPM2_NV_Read`.
const CC_NV_READ: u32 = 0x0000_014E;
/// `TPM2_NV_ReadPublic` — an index's declared shape, needed before an existing
/// index is adopted as somebody's rollback floor.
const CC_NV_READ_PUBLIC: u32 = 0x0000_0169;
const CC_UNSEAL: u32 = 0x0000_015E;
const CC_PCR_RESET: u32 = 0x0000_013D;
const CC_FLUSH_CONTEXT: u32 = 0x0000_0165;
const CC_START_AUTH_SESSION: u32 = 0x0000_0176;
const CC_GET_RANDOM: u32 = 0x0000_017B;
const CC_PCR_READ: u32 = 0x0000_017E;
const CC_POLICY_PCR: u32 = 0x0000_017F;
const CC_PCR_EXTEND: u32 = 0x0000_0182;
const CC_POLICY_GET_DIGEST: u32 = 0x0000_0189;
const ALG_SHA1: u16 = 0x0004;
const ALG_AES: u16 = 0x0006;
const ALG_KEYEDHASH: u16 = 0x0008;
/// `TPM_ALG_SHA256` — the one PCR bank and the one name algorithm this module
/// speaks. SHA-1 banks exist on every chip and are addressable by
/// [`PcrSelection`], but the policy this module seals under is SHA-256.
pub const ALG_SHA256: u16 = 0x000B;
const ALG_NULL: u16 = 0x0010;
/// `TPM_ALG_ECDSA` — the attestation key's signing scheme.
pub const ALG_ECDSA: u16 = 0x0018;
const ALG_ECC: u16 = 0x0023;
const ALG_CFB: u16 = 0x0043;
/// `TPM_GENERATED_VALUE` — the four bytes every structure the TPM *itself*
/// produced begins with, and the reason a restricted signing key cannot be
/// tricked into signing an attacker's blob: the chip refuses to sign external
/// data that starts with this, so anything carrying it was signed as an
/// attestation and nothing else. A verifier that does not check it has thrown
/// the property away.
pub const TPM_GENERATED_VALUE: u32 = 0xFF54_4347;
/// `TPM_ST_ATTEST_QUOTE` — the `type` field of a quote's `TPMS_ATTEST`.
pub const ST_ATTEST_QUOTE: u16 = 0x8018;
/// `TPM_ECC_NIST_P256`.
const ECC_NIST_P256: u16 = 0x0003;
/// `TPM_SE_POLICY` — a session whose digest actually authorises.
const SE_POLICY: u8 = 0x01;
/// `TPM_SE_TRIAL` — a session that computes a policy digest and authorises
/// nothing. This is how a policy is *learned* before anything is sealed to it.
const SE_TRIAL: u8 = 0x03;
/// The sealed object's attributes: `fixedTPM | fixedParent`.
///
/// `userWithAuth` is deliberately **CLEAR**. With it set, the USER role could
/// be satisfied by the object's (empty) auth value and the PCR policy would be
/// decoration — anyone could unseal by presenting no password at all. Clear,
/// the only road to USER authorisation is the policy session, which is the
/// entire security property. `sensitiveDataOrigin` is clear too, because the
/// caller supplies the data rather than the TPM generating it.
const SEALED_OBJECT_ATTRS: u32 = 0x0000_0002 | 0x0000_0010;
/// The SRK template's attributes: `fixedTPM | fixedParent |
/// sensitiveDataOrigin | userWithAuth | noDA | restricted | decrypt` — the
/// TCG-profile storage-key shape.
const SRK_ATTRS: u32 = 0x0003_0472;
/// The attestation key's attributes: the SRK's shape with **`sign` where the
/// SRK has `decrypt`** — `fixedTPM | fixedParent | sensitiveDataOrigin |
/// userWithAuth | noDA | restricted | sign`.
///
/// `restricted` is the bit that matters. A restricted signing key will only
/// sign data the TPM produced itself (external input beginning with
/// [`TPM_GENERATED_VALUE`] is refused outright), so a quote signed by this key
/// cannot be a blob an attacker handed the chip and asked it to sign. Clearing
/// `restricted` would leave a key that signs anything, and every verification
/// in [`crate::attest`] would still pass.
///
/// `fixedTPM | fixedParent` means the private half can never leave this chip
/// and cannot be duplicated to another — which is what makes "this key" and
/// "this machine" the same statement.
const AK_ATTRS: u32 = 0x0005_0472;
/// The largest response `/dev/tpmrm0` will hand back in one read.
const MAX_RESPONSE: usize = 4096;
/// The default character device: the kernel's **resource manager**, which
/// virtualises handles so a caller cannot exhaust the chip's slots.
pub const TPM_RM_DEVICE: &str = "/dev/tpmrm0";
/// The raw device, tried only when the resource manager is absent.
pub const TPM_RAW_DEVICE: &str = "/dev/tpm0";
// ── errors ──────────────────────────────────────────────────────────────────
/// Why a TPM operation refused. Every variant names the device, the command or
/// the field that failed, because the operator's next move is always "go look
/// at exactly that".
#[derive(Debug)]
pub enum TpmError {
/// No TPM character device could be opened. Carries every path tried.
NoDevice {
/// The paths that were tried, in order.
tried: Vec<PathBuf>,
/// The error from the last attempt.
last: std::io::Error,
},
/// The character device refused a read or a write.
Io {
/// What was being attempted.
op: &'static str,
/// The device.
path: PathBuf,
/// The OS error, verbatim.
source: std::io::Error,
},
/// The chip answered with a non-zero response code.
Rc {
/// The command that was rejected, by name.
command: &'static str,
/// The raw `TPM_RC`.
rc: u32,
/// The spec's name for it, where this module knows one.
name: &'static str,
},
/// The response was structurally wrong — truncated, a bad tag, a length
/// field that runs past the buffer. Never a panic: this module parses
/// bytes that a broken chip or a hostile emulator supplies.
Malformed {
/// The command whose response could not be parsed.
command: &'static str,
/// What was wrong.
reason: String,
},
}
impl std::fmt::Display for TpmError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
TpmError::NoDevice { tried, last } => {
let list: Vec<String> = tried.iter().map(|p| p.display().to_string()).collect();
write!(
f,
"no TPM 2.0 device: tried {} ({last}) — this machine has no TPM, or its \
driver is not in the kernel that is running",
list.join(", ")
)
}
TpmError::Io { op, path, source } => {
write!(f, "tpm: {op} {}: {source}", path.display())
}
TpmError::Rc { command, rc, name } => {
write!(f, "tpm: {command} refused with TPM_RC {rc:#010x} ({name})")
}
TpmError::Malformed { command, reason } => {
write!(f, "tpm: {command} response is malformed: {reason}")
}
}
}
}
impl std::error::Error for TpmError {}
impl TpmError {
/// `true` when the chip said the policy did not match — the *expected*
/// refusal on a machine whose boot state moved, and the one a caller wants
/// to tell apart from "no TPM" or "corrupt blob".
///
/// `TPM_RC_POLICY_FAIL` is the digest mismatch itself; `TPM_RC_INTEGRITY`
/// is what a blob sealed under a *different* SRK produces at `Load`, which
/// is the stolen-disk case.
pub fn is_policy_refusal(&self) -> bool {
matches!(self, TpmError::Rc { rc, .. } if rc_base(*rc) == 0x09D || rc_base(*rc) == 0x09F || rc_base(*rc) == 0x0A2)
}
/// `true` when the chip said there is no such NV index — the ordinary
/// answer on a machine that has never had one defined, and the ONE case
/// [`Tpm::nv_ensure_counter`] may treat as "create it". Every other refusal
/// (a chip in lockout, an authorisation failure) must not look like a fresh
/// machine, or a locked-out TPM would silently get a brand-new floor at
/// zero.
pub fn is_undefined_nv_index(&self) -> bool {
matches!(self, TpmError::Rc { rc, .. } if rc_base(*rc) == 0x08B)
}
}
/// Strip the format-1 handle/parameter/session decoration off a response code
/// so the underlying error can be compared. Format-1 codes set bit 7.
fn rc_base(rc: u32) -> u32 {
if rc & 0x80 != 0 {
rc & 0x0BF
} else {
rc & 0x0FFF
}
}
/// The spec's name for a response code, for the ones this path can actually
/// produce. Anything unknown renders as the raw hex, never as a guess.
fn rc_name(rc: u32) -> &'static str {
match rc_base(rc) {
0x081 => "TPM_RC_ASYMMETRIC",
0x082 => "TPM_RC_ATTRIBUTES",
0x083 => "TPM_RC_HASH",
0x084 => "TPM_RC_VALUE",
0x085 => "TPM_RC_HIERARCHY",
0x08A => "TPM_RC_TYPE",
0x08B => "TPM_RC_HANDLE",
0x08E => "TPM_RC_AUTH_FAIL",
0x08F => "TPM_RC_NONCE",
0x092 => "TPM_RC_SCHEME",
0x095 => "TPM_RC_SIZE",
0x097 => "TPM_RC_TAG",
0x098 => "TPM_RC_SELECTOR",
0x09B => "TPM_RC_SIGNATURE",
0x09C => "TPM_RC_KEY",
0x09D => "TPM_RC_POLICY_FAIL",
0x09F => "TPM_RC_INTEGRITY",
0x0A1 => "TPM_RC_RESERVED_BITS",
0x0A2 => "TPM_RC_BAD_AUTH",
0x100 => "TPM_RC_INITIALIZE",
0x101 => "TPM_RC_FAILURE",
0x103 => "TPM_RC_SEQUENCE",
0x10B => "TPM_RC_PRIVATE",
0x119 => "TPM_RC_HMAC",
0x120 => "TPM_RC_DISABLED",
0x124 => "TPM_RC_AUTH_TYPE",
0x125 => "TPM_RC_AUTH_MISSING",
0x126 => "TPM_RC_POLICY",
0x127 => "TPM_RC_PCR",
0x128 => "TPM_RC_PCR_CHANGED",
0x142 => "TPM_RC_COMMAND_SIZE",
0x143 => "TPM_RC_COMMAND_CODE",
0x144 => "TPM_RC_AUTHSIZE",
0x145 => "TPM_RC_AUTH_CONTEXT",
0x146 => "TPM_RC_NV_RANGE",
0x147 => "TPM_RC_NV_SIZE",
0x148 => "TPM_RC_NV_LOCKED",
0x149 => "TPM_RC_NV_AUTHORIZATION",
0x14A => "TPM_RC_NV_UNINITIALIZED",
0x14B => "TPM_RC_NV_SPACE",
0x14C => "TPM_RC_NV_DEFINED",
0x901 => "TPM_RC_CONTEXT_GAP",
0x902 => "TPM_RC_OBJECT_MEMORY",
0x903 => "TPM_RC_SESSION_MEMORY",
0x904 => "TPM_RC_MEMORY",
0x905 => "TPM_RC_SESSION_HANDLES",
0x906 => "TPM_RC_OBJECT_HANDLES",
0x921 => "TPM_RC_LOCKOUT",
0x922 => "TPM_RC_RETRY",
_ => "unnamed in this client",
}
}
// ── marshalling ─────────────────────────────────────────────────────────────
/// A big-endian command builder. TPM structures are all big-endian, so every
/// integer goes out through here rather than through a `to_be_bytes` scattered
/// across nine call sites.
#[derive(Default)]
struct Buf(Vec<u8>);
impl Buf {
fn new() -> Buf {
Buf(Vec::with_capacity(256))
}
fn u8(&mut self, v: u8) -> &mut Buf {
self.0.push(v);
self
}
fn u16(&mut self, v: u16) -> &mut Buf {
self.0.extend_from_slice(&v.to_be_bytes());
self
}
fn u32(&mut self, v: u32) -> &mut Buf {
self.0.extend_from_slice(&v.to_be_bytes());
self
}
fn raw(&mut self, v: &[u8]) -> &mut Buf {
self.0.extend_from_slice(v);
self
}
/// A `TPM2B_*`: a `u16` byte count followed by that many bytes.
fn tpm2b(&mut self, v: &[u8]) -> &mut Buf {
// A TPM2B longer than u16::MAX cannot be expressed; nothing this module
// sends comes close (the biggest is a 32-byte digest), so a truncating
// cast here would be a silent corruption of a field nobody would
// suspect. Refuse to build it instead.
assert!(v.len() <= u16::MAX as usize, "TPM2B of {} bytes cannot be marshalled", v.len());
self.u16(v.len() as u16);
self.raw(v)
}
/// An empty `TPM2B_*` (`size = 0`) — the shape of "no nonce", "no auth",
/// "no outside info".
fn empty2b(&mut self) -> &mut Buf {
self.u16(0)
}
/// The nine-byte password authorisation area: `TPM_RS_PW`, an empty nonce,
/// `continueSession`, an empty HMAC — wrapped in its own `u32` size, which
/// is what makes it an *area* rather than a session.
fn password_auth(&mut self) -> &mut Buf {
self.u32(9);
self.u32(TPM_RS_PW);
self.empty2b(); // nonceCaller
self.u8(0x01); // sessionAttributes: continueSession
self.empty2b() // hmac
}
fn into_vec(self) -> Vec<u8> {
self.0
}
fn len(&self) -> usize {
self.0.len()
}
}
/// A bounds-checked big-endian reader. Every `take` is checked, so a truncated
/// or hostile response yields a named refusal instead of a slice panic — the
/// same discipline `loader::decode_hex32` was fixed into on 2026-08-16, and for
/// the same reason: this code runs as PID 1, where a panic is a kernel panic.
///
/// `pub` because `korp-installer`'s `attest` module parses a `TPMS_ATTEST` the
/// chip signed — the same big-endian, length-prefixed grammar, arriving from
/// the same place — and a second bounds-checked reader would be forty lines of
/// twin whose two copies could drift apart on exactly the check that matters.
/// It was `pub(crate)` while `attest` lived in the same crate; the 2026-09-20
/// move to `nornir-tpm` is what widened it, and the reason is unchanged.
pub struct Reader<'a> {
bytes: &'a [u8],
pos: usize,
command: &'static str,
}
impl<'a> Reader<'a> {
pub fn new(bytes: &'a [u8], command: &'static str) -> Reader<'a> {
Reader { bytes, pos: 0, command }
}
pub fn err<T>(&self, reason: String) -> Result<T, TpmError> {
Err(TpmError::Malformed { command: self.command, reason })
}
/// How many bytes have not been consumed. A caller that must refuse
/// TRAILING bytes needs this: a `TPMS_ATTEST` with an extra field appended
/// parses cleanly and means something else.
pub fn remaining(&self) -> usize {
self.bytes.len().saturating_sub(self.pos)
}
pub fn u64(&mut self) -> Result<u64, TpmError> {
let b = self.take(8)?;
Ok(u64::from_be_bytes([b[0], b[1], b[2], b[3], b[4], b[5], b[6], b[7]]))
}
fn take(&mut self, n: usize) -> Result<&'a [u8], TpmError> {
let end = self.pos.checked_add(n).unwrap_or(usize::MAX);
if end > self.bytes.len() {
return self.err(format!(
"wanted {n} bytes at offset {} but the response is {} bytes",
self.pos,
self.bytes.len()
));
}
let out = &self.bytes[self.pos..end];
self.pos = end;
Ok(out)
}
pub fn u8(&mut self) -> Result<u8, TpmError> {
Ok(self.take(1)?[0])
}
pub fn u16(&mut self) -> Result<u16, TpmError> {
let b = self.take(2)?;
Ok(u16::from_be_bytes([b[0], b[1]]))
}
pub fn u32(&mut self) -> Result<u32, TpmError> {
let b = self.take(4)?;
Ok(u32::from_be_bytes([b[0], b[1], b[2], b[3]]))
}
/// Read a `TPM2B_*` and hand back its body.
pub fn tpm2b(&mut self) -> Result<&'a [u8], TpmError> {
let n = self.u16()? as usize;
self.take(n)
}
}
// ── PCR selection ───────────────────────────────────────────────────────────
/// Which PCRs a seal is bound to, in which bank.
///
/// The mask is a bitmap over PCR indices 0–23, bit *n* being PCR *n*, which is
/// exactly how `TPML_PCR_SELECTION` spells it on the wire — so the value that
/// is written into a sealed blob's header and the value that goes to the chip
/// are the same integer, and there is no second encoding to disagree with.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct PcrSelection {
/// The bank — `TPM_ALG_SHA256` in every shape this crate ships.
pub alg: u16,
/// Bitmap over PCR 0–23.
pub mask: u32,
}
impl PcrSelection {
/// The default binding: **PCR 0 and PCR 7** in the SHA-256 bank.
///
/// PCR 0 is the firmware code — a stolen disk in a different machine
/// measures a different one. PCR 7 is the Secure Boot policy — turning
/// Secure Boot off, or enrolling a foreign key, moves it.
///
/// # This choice is MEASURED, not reasoned
///
/// The appliance seals while running off the *installer ISO* and unseals
/// while running off the *installed disk*. Two boot chains, one firmware. A
/// PCR that differs between them makes every machine install cleanly and
/// then refuse its own identity on first boot. `tests/tpm_kvm_pcrs.rs`
/// boots OVMF twice against the same emulated chip and reads the values
/// back; on oden, 2026-08-16 (OVMF_CODE_4M, swtpm 0.10.1):
///
/// ```text
/// from a CD-ROM from a hard disk
/// PCR 0 012103E0F956C315… 012103E0F956C315… SAME
/// PCR 1 91FE52953A8C078E… A339966DDE8F6FE2… DIFFERS
/// PCR 2 0AF5E579EDCC84BE… 0AF5E579EDCC84BE… same
/// PCR 7 B5710BF57D25623E… B5710BF57D25623E… SAME
/// ```
///
/// **PCR 1 is why this is not "PCRs 0–7".** It is the platform
/// *configuration* PCR and the attached media is part of that
/// configuration, so the binding that reads like the careful choice is the
/// one that breaks every appliance.
///
/// # PCR 4 is deliberately absent, and that is a real limitation
///
/// PCR 4 measures the boot-loader code. The two boots above agree on it
/// only because neither found anything to boot, so that agreement proves
/// nothing; on a real install-then-boot transition it will differ. Sealing
/// to 4 needs a **re-seal on the first successful boot**, which is a
/// separate, provable change. The honest statement today is that this seal
/// binds to *this machine's firmware and Secure Boot state*, and not to
/// *which loader ran*.
pub const DEFAULT: PcrSelection = PcrSelection { alg: ALG_SHA256, mask: (1 << 0) | (1 << 7) };
/// The PCR indices this selection covers, ascending.
pub fn indices(&self) -> Vec<u32> {
(0..24).filter(|i| self.mask & (1 << i) != 0).collect()
}
/// `true` when no PCR at all is selected — a "policy" that binds to
/// nothing and would unseal in any state. Refused by [`Tpm::seal`].
pub fn is_empty(&self) -> bool {
self.mask & 0x00FF_FFFF == 0
}
/// The bank's name, for console lines.
pub fn bank(&self) -> &'static str {
match self.alg {
ALG_SHA256 => "sha256",
ALG_SHA1 => "sha1",
_ => "unknown-bank",
}
}
/// [`PcrSelection::DEFAULT`] **plus PCR 4** — the binding an *installed,
/// running* appliance holds its identity under.
///
/// PCR 4 is where the firmware measures the PE it loaded, so this is the
/// difference between "this machine's firmware and Secure Boot state" and
/// "**this loader** on this machine". Without it a substituted korp-boot,
/// signed by a key the firmware still trusts or run with Secure Boot
/// satisfied some other way, unseals the appliance's identity.
///
/// It cannot be the install-time binding, because the installer measures
/// its OWN loader and the installed system measures a different one. It is
/// reached by a re-seal on the first successful boot — see
/// [`crate::wgseal::reseal_to`].
///
/// PCR 1 stays out for the reason [`PcrSelection::DEFAULT`] documents, and
/// that is measured, not reasoned.
pub const WITH_LOADER: PcrSelection =
PcrSelection { alg: ALG_SHA256, mask: (1 << 0) | (1 << 4) | (1 << 7) };
/// Marshal as `TPML_PCR_SELECTION` (one selection, `sizeofSelect = 3`).
fn marshal(&self, b: &mut Buf) {
b.u32(1);
b.u16(self.alg);
b.u8(3);
b.u8((self.mask & 0xFF) as u8);
b.u8(((self.mask >> 8) & 0xFF) as u8);
b.u8(((self.mask >> 16) & 0xFF) as u8);
}
/// Parse the operator's spelling: `sha256:0,7` or bare `0,7` (SHA-256
/// assumed). Refuses by name — this string can arrive off the kernel
/// command line, i.e. off the plaintext ESP.
pub fn parse(s: &str) -> Result<PcrSelection, String> {
let s = s.trim();
let (alg, list) = match s.split_once(':') {
Some((bank, rest)) => {
let alg = match bank.trim().to_ascii_lowercase().as_str() {
"sha256" => ALG_SHA256,
"sha1" => ALG_SHA1,
other => {
return Err(format!(
"'{other}' is not a PCR bank this client speaks (sha256, sha1)"
))
}
};
(alg, rest)
}
None => (ALG_SHA256, s),
};
let mut mask = 0u32;
let list = list.trim();
if list.is_empty() {
return Err("no PCR indices given — a seal bound to no PCR unseals in any state".into());
}
for part in list.split(',') {
let part = part.trim();
let n: u32 = part
.parse()
.map_err(|_| format!("'{part}' is not a PCR index (0–23)"))?;
if n > 23 {
return Err(format!("PCR {n} is out of range — the platform has 0–23"));
}
mask |= 1 << n;
}
Ok(PcrSelection { alg, mask })
}
}
impl std::fmt::Display for PcrSelection {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let idx: Vec<String> = self.indices().iter().map(|i| i.to_string()).collect();
write!(f, "{}:{}", self.bank(), idx.join(","))
}
}
// ── the sealed blob, as it lands on disk ────────────────────────────────────
/// The magic that opens a sealed-key file. Eight bytes, so a file that is not
/// one is refused before a single length field is believed.
pub const SEAL_MAGIC: &[u8; 8] = b"KORPSEAL";
/// The on-disk format version. Bumping it is a breaking change to every
/// installed appliance, so it is checked exactly, never `>=`.
pub const SEAL_VERSION: u16 = 1;
/// What `Create` handed back, in the form that goes on the partition.
///
/// `private` is encrypted **to this chip's SRK**; `public` is the object's
/// public area including its `authPolicy`. Neither is a secret — the whole
/// point of a seal is that the file is inert off the machine that made it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SealedBlob {
/// The PCR set the seal is bound to, carried so the unsealer does not have
/// to be told separately — a blob and its policy travel together or the
/// policy is a second source of truth waiting to disagree.
pub pcrs: PcrSelection,
/// `TPM2B_PRIVATE` body: the encrypted sensitive area.
pub private: Vec<u8>,
/// `TPM2B_PUBLIC` body: the `TPMT_PUBLIC`, carrying the `authPolicy`.
pub public: Vec<u8>,
}
/// Why a sealed-key file was refused. Distinct from [`TpmError`] on purpose:
/// "this file is not a seal" and "this chip will not open this seal" are
/// different operator problems with different next moves.
#[derive(Debug, PartialEq, Eq)]
pub enum BlobError {
/// The file does not begin with [`SEAL_MAGIC`].
NotASeal {
/// The first bytes found, rendered readably.
found: String,
},
/// A format version this build does not speak.
Version {
/// What the file claims.
found: u16,
},
/// A length field runs past the end of the file, or the file is short.
Truncated {
/// The field being read.
field: &'static str,
/// How many bytes it wanted.
wanted: usize,
/// How many were left.
available: usize,
},
/// The blake3 checksum over the body does not match the header's.
Checksum,
/// The header's PCR selection is empty or in an unknown bank.
BadSelection {
/// Why.
reason: String,
},
}
impl std::fmt::Display for BlobError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
BlobError::NotASeal { found } => write!(
f,
"not a sealed key file: expected the magic KORPSEAL, found {found} — refusing \
to guess at bytes that are not this format"
),
BlobError::Version { found } => write!(
f,
"sealed key file is format version {found}, this build speaks {SEAL_VERSION} \
— refusing rather than mis-parsing a layout it does not know"
),
BlobError::Truncated { field, wanted, available } => write!(
f,
"sealed key file is truncated: field '{field}' wants {wanted} bytes, {available} \
remain"
),
BlobError::Checksum => write!(
f,
"sealed key file fails its own blake3 checksum — the bytes changed since they \
were sealed; this is corruption, not a policy refusal"
),
BlobError::BadSelection { reason } => {
write!(f, "sealed key file's PCR selection is unusable: {reason}")
}
}
}
}
impl std::error::Error for BlobError {}
impl SealedBlob {
/// Render the blob as the bytes that go on the partition.
///
/// ```text
/// 0 8 magic "KORPSEAL"
/// 8 2 version (u16 BE)
/// 10 2 pcr bank alg (u16 BE)
/// 12 4 pcr mask (u32 BE)
/// 16 32 blake3 of everything from offset 48 on
/// 48 2 private length 50 .. private
/// + 2 public length + .. public
/// ```
///
/// The checksum is not security — a `private` an attacker rewrites is
/// refused by the *chip*, at `Load`, with `TPM_RC_INTEGRITY`. It is here so
/// that "this file rotted on a FAT partition" and "this chip is not the one
/// that sealed it" are told apart by name instead of both arriving as one
/// opaque TPM refusal.
pub fn encode(&self) -> Vec<u8> {
let mut body = Buf::new();
body.tpm2b(&self.private);
body.tpm2b(&self.public);
let body = body.into_vec();
let mut out = Buf::new();
out.raw(SEAL_MAGIC);
out.u16(SEAL_VERSION);
out.u16(self.pcrs.alg);
out.u32(self.pcrs.mask);
out.raw(blake3::hash(&body).as_bytes());
out.raw(&body);
out.into_vec()
}
/// Parse a sealed-key file. **Every** length is bounds-checked and the
/// function is total — `korp-start` feeds it a file off a FAT partition
/// while running as PID 1, where a panic is a kernel panic.
pub fn decode(bytes: &[u8]) -> Result<SealedBlob, BlobError> {
let need = |field: &'static str, wanted: usize, available: usize| BlobError::Truncated {
field,
wanted,
available,
};
if bytes.len() < 8 || &bytes[..8] != SEAL_MAGIC {
let head: String = bytes
.iter()
.take(8)
.map(|b| {
if b.is_ascii_graphic() {
(*b as char).to_string()
} else {
format!("\\x{b:02x}")
}
})
.collect();
return Err(BlobError::NotASeal { found: format!("'{head}'") });
}
if bytes.len() < 48 {
return Err(need("header", 48, bytes.len()));
}
let version = u16::from_be_bytes([bytes[8], bytes[9]]);
if version != SEAL_VERSION {
return Err(BlobError::Version { found: version });
}
let alg = u16::from_be_bytes([bytes[10], bytes[11]]);
let mask = u32::from_be_bytes([bytes[12], bytes[13], bytes[14], bytes[15]]);
let sum: [u8; 32] = bytes[16..48].try_into().expect("48 - 16 is 32");
let body = &bytes[48..];
if blake3::hash(body).as_bytes() != &sum {
return Err(BlobError::Checksum);
}
// Bounds-checked walk of the two TPM2Bs.
let mut pos = 0usize;
let field = |name: &'static str, pos: &mut usize| -> Result<Vec<u8>, BlobError> {
if body.len() < *pos + 2 {
return Err(need(name, 2, body.len().saturating_sub(*pos)));
}
let n = u16::from_be_bytes([body[*pos], body[*pos + 1]]) as usize;
*pos += 2;
if body.len() < *pos + n {
return Err(need(name, n, body.len().saturating_sub(*pos)));
}
let out = body[*pos..*pos + n].to_vec();
*pos += n;
Ok(out)
};
let private = field("private", &mut pos)?;
let public = field("public", &mut pos)?;
let pcrs = PcrSelection { alg, mask };
if pcrs.is_empty() {
return Err(BlobError::BadSelection {
reason: "no PCR is selected — a seal bound to nothing would unseal in any boot \
state, which is the failure this whole mechanism exists to prevent"
.into(),
});
}
if alg != ALG_SHA256 && alg != ALG_SHA1 {
return Err(BlobError::BadSelection {
reason: format!("bank {alg:#06x} is not one this client speaks"),
});
}
Ok(SealedBlob { pcrs, private, public })
}
}
/// A secret that zeroes itself. Not a substitute for a real secrets crate, but
/// it means the unsealed WireGuard private key is not left lying in a freed
/// heap page for the rest of the boot.
pub struct Secret(Vec<u8>);
impl Secret {
/// The bytes, for as long as the guard lives.
pub fn as_bytes(&self) -> &[u8] {
&self.0
}
/// Length, without exposing the bytes.
pub fn len(&self) -> usize {
self.0.len()
}
/// `true` when the unsealed payload was empty.
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
}
impl Drop for Secret {
fn drop(&mut self) {
// A plain loop, through a volatile write so the optimiser may not
// decide that storing into a dying allocation is dead code.
for b in self.0.iter_mut() {
unsafe { std::ptr::write_volatile(b, 0) };
}
}
}
impl std::fmt::Debug for Secret {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "Secret({} bytes, redacted)", self.0.len())
}
}
// ── attestation types ───────────────────────────────────────────────────────
/// The attributes an NV **monotonic counter** is defined with: `OWNERWRITE |
/// OWNERREAD | TPM_NT_COUNTER | NO_DA`.
///
/// `TPM_NT_COUNTER` (the 4-bit type field at bits 4–7) is the whole point — the
/// chip refuses any write other than an increment, so a rollback floor kept
/// here cannot be walked backwards by anyone who holds the disk. `NO_DA` keeps
/// a failed authorisation from arming the dictionary-attack lockout and
/// bricking the boot path for an hour.
const NV_COUNTER_ATTRS: u32 = 0x0000_0002 | 0x0002_0000 | (1 << 4) | 0x0200_0000;
/// A P-256 public point — the attestation key as a verifier sees it.
///
/// This is the ONLY thing about the AK that ever leaves the chip. The private
/// half is `fixedTPM`, so it has no on-disk form to steal and no way to be
/// duplicated onto a second machine.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct AkPublic {
/// The affine x coordinate, 32 bytes big-endian.
pub x: [u8; 32],
/// The affine y coordinate, 32 bytes big-endian.
pub y: [u8; 32],
}
impl AkPublic {
/// SEC1 uncompressed encoding (`0x04 ‖ x ‖ y`) — what every ECDSA verifier
/// takes, and the form the hub pins.
pub fn sec1(&self) -> [u8; 65] {
let mut out = [0u8; 65];
out[0] = 0x04;
out[1..33].copy_from_slice(&self.x);
out[33..65].copy_from_slice(&self.y);
out
}
/// Parse a SEC1 uncompressed point back, refusing anything else by name —
/// these bytes come off an operator's clipboard.
pub fn from_sec1(bytes: &[u8]) -> Result<AkPublic, String> {
if bytes.len() != 65 || bytes[0] != 0x04 {
return Err(format!(
"an attestation key is 65 bytes beginning 0x04 (SEC1 uncompressed); this is {} \
bytes beginning {:#04x}",
bytes.len(),
bytes.first().copied().unwrap_or(0)
));
}
let mut k = AkPublic { x: [0; 32], y: [0; 32] };
k.x.copy_from_slice(&bytes[1..33]);
k.y.copy_from_slice(&bytes[33..65]);
Ok(k)
}
/// The 130-hex-character spelling an operator carries to the hub: one line,
/// no whitespace, nothing secret.
pub fn to_hex(&self) -> String {
hex(&self.sec1())
}
/// Parse [`AkPublic::to_hex`] back, refusing by name.
pub fn from_hex(s: &str) -> Result<AkPublic, String> {
let s = s.trim();
if s.len() != 130 || !s.bytes().all(|b| b.is_ascii_hexdigit()) {
return Err(format!(
"an attestation key is 130 hex characters; got {} characters",
s.len()
));
}
let mut bytes = [0u8; 65];
for (i, b) in bytes.iter_mut().enumerate() {
*b = u8::from_str_radix(&s[i * 2..i * 2 + 2], 16).map_err(|e| e.to_string())?;
}
AkPublic::from_sec1(&bytes)
}
}
/// An NV index's declared shape, as `TPM2_NV_ReadPublic` states it.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct NvPublic {
/// The index.
pub index: u32,
/// The name algorithm.
pub name_alg: u16,
/// `TPMA_NV`. Bits 4–7 are the type — 1 is `TPM_NT_COUNTER`.
pub attributes: u32,
/// How many bytes the index holds. A counter is 8.
pub data_size: u16,
}
impl NvPublic {
/// `true` when this index is a monotonic counter — the only shape a
/// rollback floor may live in.
pub fn is_counter(&self) -> bool {
(self.attributes >> 4) & 0xF == 1 && self.data_size == 8
}
}
/// A monotonic counter, and whether this call is what created it.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct NvCounter {
/// The NV index it lives at.
pub index: u32,
/// Its current value.
pub value: u64,
/// `true` when this call defined it — a FIRST install, not a later boot.
/// A caller that treats every boot as a first install would reset the
/// meaning of the floor it is protecting.
pub created: bool,
}
/// A loaded attestation key: the chip-side handle plus the point that
/// identifies it. The handle must be flushed; the point is what travels.
#[derive(Debug)]
pub struct AttestationKey {
/// The transient object handle inside the chip.
pub handle: u32,
/// The public point.
pub public: AkPublic,
}
/// What `TPM2_Quote` handed back: the signed structure and the signature over
/// it. `quoted` is the marshalled `TPMS_ATTEST` **body** — the exact bytes the
/// signature covers, so a verifier hashes those and never something it
/// re-derived.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct QuoteOutput {
/// `TPM2B_ATTEST` body — a `TPMS_ATTEST`.
pub quoted: Vec<u8>,
/// ECDSA `r`, 32 bytes big-endian.
pub sig_r: [u8; 32],
/// ECDSA `s`, 32 bytes big-endian.
pub sig_s: [u8; 32],
}
/// What a seal produced, with the evidence a console line needs.
#[derive(Debug)]
pub struct SealReport {
/// The blob to write to disk.
pub blob: SealedBlob,
/// The policy digest the object was sealed under — the one number that
/// says "this is the boot state".
pub policy_digest: [u8; 32],
/// The live PCR values at seal time, for the log.
pub pcrs: Vec<(u32, Vec<u8>)>,
}
// ── the client ──────────────────────────────────────────────────────────────
/// Anything a TPM command can be written to and a response read back from.
///
/// Two shapes exist and they behave differently, which is the whole reason this
/// is a trait: a character device hands back exactly one response per `read(2)`,
/// while a socket is a byte stream that may deliver a response in pieces. The
/// framing loop in [`Tpm::transmit`] handles both by trusting only the size
/// field in the response header.
trait Wire: Read + Write + Send {}
impl<T: Read + Write + Send> Wire for T {}
/// An open TPM 2.0 device.
pub struct Tpm {
dev: Box<dyn Wire>,
path: PathBuf,
}
impl Tpm {
/// Open a specific device node.
pub fn open(path: &Path) -> Result<Tpm, TpmError> {
let dev = std::fs::OpenOptions::new()
.read(true)
.write(true)
.open(path)
.map_err(|last| TpmError::NoDevice { tried: vec![path.to_path_buf()], last })?;
Ok(Tpm { dev: Box::new(dev), path: path.to_path_buf() })
}
/// Connect to a TPM behind a **unix socket** — what `swtpm socket` presents,
/// and therefore what a QEMU guest's emulated chip is on the host side.
///
/// This is what makes the KVM proof possible at all: the *same* client code
/// that runs on the appliance can talk to the *same* emulated chip a guest
/// just booted against, so a key sealed while one thing was booted can be
/// tested against the PCR state left by booting another.
#[cfg(unix)]
pub fn connect_unix(path: &Path) -> Result<Tpm, TpmError> {
let sock = std::os::unix::net::UnixStream::connect(path)
.map_err(|last| TpmError::NoDevice { tried: vec![path.to_path_buf()], last })?;
// Without a timeout a client that mis-frames a command waits forever,
// and a test that hangs is worse than one that fails.
let _ = sock.set_read_timeout(Some(std::time::Duration::from_secs(30)));
Ok(Tpm { dev: Box::new(sock), path: path.to_path_buf() })
}
/// Open the resource manager, falling back to the raw device.
///
/// The fallback is not superstition: an initramfs that has `tpm_crb` but no
/// `tpm_vtpm_proxy`-backed `/dev/tpmrm0` is a real kernel configuration,
/// and refusing to seal on such a machine over a device *name* would be
/// trading one distro assumption for another.
pub fn open_default() -> Result<Tpm, TpmError> {
let mut tried = Vec::new();
let mut last = None;
for p in [TPM_RM_DEVICE, TPM_RAW_DEVICE] {
let path = PathBuf::from(p);
match Tpm::open(&path) {
Ok(t) => return Ok(t),
Err(TpmError::NoDevice { last: e, .. }) => {
tried.push(path);
last = Some(e);
}
Err(other) => return Err(other),
}
}
Err(TpmError::NoDevice {
tried,
last: last.unwrap_or_else(|| std::io::Error::other("no attempt was made")),
})
}
/// Which device this client is talking to.
pub fn device(&self) -> &Path {
&self.path
}
/// Send one command, read one response, check the header and the response
/// code. Returns the response **body** — everything after `rc`.
fn transmit(&mut self, command: &'static str, cmd: Vec<u8>) -> Result<Vec<u8>, TpmError> {
self.dev.write_all(&cmd).map_err(|source| TpmError::Io {
op: "write a command to",
path: self.path.clone(),
source,
})?;
// Frame the response by its own size field rather than by "one read is
// one message". That is true of a character device and NOT true of a
// socket, where `swtpm` may hand back the header and the body in
// separate segments — a client that assumed otherwise would report a
// malformed response for a perfectly good one, intermittently.
let mut buf: Vec<u8> = Vec::with_capacity(1024);
let mut chunk = [0u8; MAX_RESPONSE];
let declared = loop {
if buf.len() >= 6 {
let size = u32::from_be_bytes([buf[2], buf[3], buf[4], buf[5]]) as usize;
if size < 10 || size > MAX_RESPONSE {
return Reader::new(&buf, command)
.err(format!("header declares a {size}-byte response, which is not one"));
}
if buf.len() >= size {
break size;
}
}
let n = self.dev.read(&mut chunk).map_err(|source| TpmError::Io {
op: "read a response from",
path: self.path.clone(),
source,
})?;
if n == 0 {
return Reader::new(&buf, command).err(format!(
"the device closed after {} bytes without completing a response",
buf.len()
));
}
buf.extend_from_slice(&chunk[..n]);
};
if buf.len() != declared {
return Reader::new(&buf, command).err(format!(
"header declares {declared} bytes, the device handed back {}",
buf.len()
));
}
let mut r = Reader::new(&buf, command);
let tag = r.u16()?;
if tag != TPM_ST_NO_SESSIONS && tag != TPM_ST_SESSIONS {
return r.err(format!("response tag {tag:#06x} is neither NO_SESSIONS nor SESSIONS"));
}
let _size = r.u32()?; // already checked against the framing above
let rc = r.u32()?;
if rc != 0 {
return Err(TpmError::Rc { command, rc, name: rc_name(rc) });
}
Ok(buf[10..].to_vec())
}
/// Build a command header around an already-marshalled tail.
fn command(tag: u16, code: u32, tail: Buf) -> Vec<u8> {
let mut out = Buf::new();
out.u16(tag);
out.u32((10 + tail.len()) as u32);
out.u32(code);
out.raw(&tail.into_vec());
out.into_vec()
}
/// `TPM2_GetRandom` — used for session nonces, and the cheapest possible
/// proof that the marshalling reaches a live chip.
pub fn get_random(&mut self, n: u16) -> Result<Vec<u8>, TpmError> {
let mut t = Buf::new();
t.u16(n);
let body = self.transmit("GetRandom", Tpm::command(TPM_ST_NO_SESSIONS, CC_GET_RANDOM, t))?;
let mut r = Reader::new(&body, "GetRandom");
Ok(r.tpm2b()?.to_vec())
}
/// `TPM2_PCR_Read` — the diagnostic that makes a policy refusal legible:
/// the operator can see *which* PCR moved.
pub fn pcr_read(&mut self, sel: &PcrSelection) -> Result<Vec<(u32, Vec<u8>)>, TpmError> {
let mut t = Buf::new();
sel.marshal(&mut t);
let body = self.transmit("PCR_Read", Tpm::command(TPM_ST_NO_SESSIONS, CC_PCR_READ, t))?;
let mut r = Reader::new(&body, "PCR_Read");
let _update_counter = r.u32()?;
// pcrSelectionOut — the chip echoes what it actually read, which can be
// a SUBSET when a selection spans more than one read. Trust the echo,
// not the request: pairing requested indices with returned digests
// would silently mislabel every value after the first gap.
let count = r.u32()?;
let mut indices: Vec<u32> = Vec::new();
for _ in 0..count {
let _alg = r.u16()?;
let size_of_select = r.u8()? as usize;
let bits = r.take(size_of_select)?;
for (byte, b) in bits.iter().enumerate() {
for bit in 0..8u32 {
if b & (1 << bit) != 0 {
indices.push(byte as u32 * 8 + bit);
}
}
}
}
let digest_count = r.u32()? as usize;
if digest_count != indices.len() {
return r.err(format!(
"the chip returned {digest_count} digests for {} selected PCRs",
indices.len()
));
}
let mut out = Vec::with_capacity(digest_count);
for idx in indices {
out.push((idx, r.tpm2b()?.to_vec()));
}
Ok(out)
}
/// `TPM2_PCR_Extend` in the SHA-256 bank — how a test *moves* the boot
/// state so a seal can be watched refusing.
///
/// This is a real, irreversible change to the running platform's PCR: a PCR
/// only resets on a platform reset. Nothing in the appliance's boot path
/// calls it; it exists so the guard can be seen red.
pub fn pcr_extend(&mut self, index: u32, digest: &[u8; 32]) -> Result<(), TpmError> {
let mut t = Buf::new();
t.u32(index);
t.password_auth();
t.u32(1); // one digest
t.u16(ALG_SHA256);
t.raw(digest);
self.transmit("PCR_Extend", Tpm::command(TPM_ST_SESSIONS, CC_PCR_EXTEND, t))?;
Ok(())
}
/// `TPM2_PCR_Reset` — put a *resettable* PCR back to zero.
///
/// Only PCRs 16 and 23 are resettable from locality 0; the platform PCRs
/// (0–7) are not, by design, and asking gets `TPM_RC_LOCALITY`. This exists
/// so a red probe that extends a PCR to watch a seal refuse can leave the
/// machine exactly as it found it — a proof that permanently changes the
/// box it runs on is a proof nobody runs twice.
pub fn pcr_reset(&mut self, index: u32) -> Result<(), TpmError> {
let mut t = Buf::new();
t.u32(index);
t.password_auth();
self.transmit("PCR_Reset", Tpm::command(TPM_ST_SESSIONS, CC_PCR_RESET, t))?;
Ok(())
}
/// `TPM2_StartAuthSession` — unsalted (`tpmKey = TPM_RH_NULL`), unbound
/// (`bind = TPM_RH_NULL`), no symmetric algorithm, SHA-256.
fn start_auth_session(&mut self, kind: u8) -> Result<u32, TpmError> {
// A caller nonce of at least the hash size, from the chip's own RNG.
// The value does not carry weight without an HMAC session, but sending
// a fixed one would make every session on the fleet identical, and the
// spec requires a non-empty caller nonce here.
let nonce = self.get_random(32)?;
let mut t = Buf::new();
t.u32(TPM_RH_NULL); // tpmKey — no salt
t.u32(TPM_RH_NULL); // bind — no bound entity
t.tpm2b(&nonce);
t.empty2b(); // encryptedSalt
t.u8(kind);
t.u16(ALG_NULL); // symmetric: TPMT_SYM_DEF of TPM_ALG_NULL ends here
t.u16(ALG_SHA256); // authHash
let name = if kind == SE_TRIAL { "StartAuthSession(TRIAL)" } else { "StartAuthSession(POLICY)" };
let body = self.transmit(name, Tpm::command(TPM_ST_NO_SESSIONS, CC_START_AUTH_SESSION, t))?;
let mut r = Reader::new(&body, name);
let handle = r.u32()?;
let _nonce_tpm = r.tpm2b()?;
Ok(handle)
}
/// `TPM2_PolicyPCR` with an **empty** `pcrDigest`, which means "bind to the
/// PCRs as they are right now" — in a trial session that computes the
/// digest, in a policy session that checks it.
fn policy_pcr(&mut self, session: u32, sel: &PcrSelection) -> Result<(), TpmError> {
let mut t = Buf::new();
t.u32(session);
t.empty2b(); // pcrDigest: empty ⇒ use the current values
sel.marshal(&mut t);
self.transmit("PolicyPCR", Tpm::command(TPM_ST_NO_SESSIONS, CC_POLICY_PCR, t))?;
Ok(())
}
/// `TPM2_PolicyGetDigest` — read the digest a session has accumulated.
fn policy_get_digest(&mut self, session: u32) -> Result<[u8; 32], TpmError> {
let mut t = Buf::new();
t.u32(session);
let body =
self.transmit("PolicyGetDigest", Tpm::command(TPM_ST_NO_SESSIONS, CC_POLICY_GET_DIGEST, t))?;
let mut r = Reader::new(&body, "PolicyGetDigest");
let d = r.tpm2b()?;
<[u8; 32]>::try_from(d).map_err(|_| TpmError::Malformed {
command: "PolicyGetDigest",
reason: format!("policy digest is {} bytes, not the 32 SHA-256 must be", d.len()),
})
}
/// `TPM2_FlushContext`. The handle is a **parameter**, not a handle-area
/// entry — the one command in this module where that is true, and getting
/// it wrong costs a `TPM_RC_COMMAND_SIZE` that looks like a marshalling bug
/// somewhere else entirely.
pub fn flush(&mut self, handle: u32) -> Result<(), TpmError> {
let mut t = Buf::new();
t.u32(handle);
self.transmit("FlushContext", Tpm::command(TPM_ST_NO_SESSIONS, CC_FLUSH_CONTEXT, t))?;
Ok(())
}
/// Flush without caring — for cleanup paths where the interesting error has
/// already been captured and a second one would only bury it.
fn flush_quietly(&mut self, handle: u32) {
let _ = self.flush(handle);
}
/// Compute the policy digest for a PCR selection using a **trial** session,
/// which authorises nothing and exists exactly for this.
pub fn policy_digest_for(&mut self, sel: &PcrSelection) -> Result<[u8; 32], TpmError> {
let session = self.start_auth_session(SE_TRIAL)?;
let out = (|| {
self.policy_pcr(session, sel)?;
self.policy_get_digest(session)
})();
self.flush_quietly(session);
out
}
/// `TPM2_CreatePrimary` on the owner hierarchy, ECC P-256 storage key.
///
/// **Deterministic**: the TPM derives a primary key from its own hierarchy
/// seed and the exact template bytes, so the same template on the same chip
/// always yields the same key, and no other chip can. That is what makes a
/// sealed blob machine-bound without any of it being written down. ECC
/// rather than RSA because an RSA-2048 primary costs seconds of key
/// generation on real hardware and this runs on every boot.
fn create_primary_srk(&mut self) -> Result<u32, TpmError> {
Ok(self.create_primary_srk_full()?.0)
}
/// [`Tpm::create_primary_srk`], keeping the `outPublic` the chip returned.
fn create_primary_srk_full(&mut self) -> Result<(u32, Vec<u8>), TpmError> {
let mut public = Buf::new();
public.u16(ALG_ECC);
public.u16(ALG_SHA256); // nameAlg
public.u32(SRK_ATTRS);
public.empty2b(); // authPolicy: none, the SRK is used with its (empty) auth
// TPMS_ECC_PARMS
public.u16(ALG_AES); // symmetric.algorithm
public.u16(128); // symmetric.keyBits
public.u16(ALG_CFB); // symmetric.mode
public.u16(ALG_NULL); // scheme
public.u16(ECC_NIST_P256); // curveID
public.u16(ALG_NULL); // kdf
// TPMS_ECC_POINT unique — empty, the TPM fills it in
public.empty2b();
public.empty2b();
let public = public.into_vec();
self.create_primary(&public, "CreatePrimary(SRK)")
}
/// The shared `TPM2_CreatePrimary` tail: send a marshalled `TPMT_PUBLIC` on
/// the owner hierarchy and hand back the handle **and** the `outPublic` the
/// chip filled in.
///
/// The public area is not decoration: for an attestation key it carries the
/// point a verifier checks signatures against, and it is the only way that
/// point ever leaves the chip.
fn create_primary(
&mut self,
public: &[u8],
what: &'static str,
) -> Result<(u32, Vec<u8>), TpmError> {
let mut t = Buf::new();
t.u32(TPM_RH_OWNER);
t.password_auth();
// inSensitive: TPM2B_SENSITIVE_CREATE wrapping two empty TPM2Bs
t.u16(4);
t.empty2b(); // userAuth
t.empty2b(); // data
t.tpm2b(public); // inPublic
t.empty2b(); // outsideInfo
t.u32(0); // creationPCR: none
let body = self.transmit(what, Tpm::command(TPM_ST_SESSIONS, CC_CREATE_PRIMARY, t))?;
let mut r = Reader::new(&body, what);
let handle = r.u32()?;
let _parameter_size = r.u32()?;
let out_public = r.tpm2b()?.to_vec();
Ok((handle, out_public))
}
/// Seal `secret` under a PCR policy.
///
/// The secret goes into a KEYEDHASH object whose `authPolicy` is the PCR
/// policy digest and whose `userWithAuth` is clear, so the only way back to
/// it is a policy session over the same PCRs.
pub fn seal(&mut self, secret: &[u8], sel: &PcrSelection) -> Result<SealReport, TpmError> {
if sel.is_empty() {
return Err(TpmError::Malformed {
command: "seal",
reason: "the PCR selection is empty — a seal bound to no PCR unseals in ANY \
boot state, which is not a seal"
.into(),
});
}
if secret.is_empty() || secret.len() > 128 {
return Err(TpmError::Malformed {
command: "seal",
reason: format!(
"a sealed data object carries 1–128 bytes; {} were offered",
secret.len()
),
});
}
let policy_digest = self.policy_digest_for(sel)?;
let pcrs = self.pcr_read(sel)?;
let srk = self.create_primary_srk()?;
let mut public = Buf::new();
public.u16(ALG_KEYEDHASH);
public.u16(ALG_SHA256); // nameAlg
public.u32(SEALED_OBJECT_ATTRS);
public.tpm2b(&policy_digest); // authPolicy — THE binding
public.u16(ALG_NULL); // TPMS_KEYEDHASH_PARMS.scheme: plain sealed data
public.empty2b(); // unique
let public = public.into_vec();
let mut t = Buf::new();
t.u32(srk);
t.password_auth();
// inSensitive: userAuth empty, data = the secret
let mut sensitive = Buf::new();
sensitive.empty2b();
sensitive.tpm2b(secret);
let sensitive = sensitive.into_vec();
t.tpm2b(&sensitive);
t.tpm2b(&public);
t.empty2b(); // outsideInfo
t.u32(0); // creationPCR
let result = self.transmit("Create", Tpm::command(TPM_ST_SESSIONS, CC_CREATE, t));
self.flush_quietly(srk);
let body = result?;
let mut r = Reader::new(&body, "Create");
let _parameter_size = r.u32()?;
let out_private = r.tpm2b()?.to_vec();
let out_public = r.tpm2b()?.to_vec();
Ok(SealReport {
blob: SealedBlob { pcrs: *sel, private: out_private, public: out_public },
policy_digest,
pcrs,
})
}
/// Unseal a blob: re-derive the SRK, `Load` the object under it, run a
/// **policy** session over the blob's own PCR selection, and `Unseal`.
///
/// Three distinct refusals live here, and they are different problems:
/// `Load` failing with `TPM_RC_INTEGRITY` means the blob was sealed by a
/// different chip (or edited); `Unseal` failing with `TPM_RC_POLICY_FAIL`
/// means this chip made it but the boot state has moved; anything else is
/// the chip or the wiring.
pub fn unseal(&mut self, blob: &SealedBlob) -> Result<Secret, TpmError> {
let srk = self.create_primary_srk()?;
let loaded = self.load(srk, blob);
self.flush_quietly(srk);
let object = loaded?;
let out = (|| {
let session = self.start_auth_session(SE_POLICY)?;
let unsealed = (|| {
self.policy_pcr(session, &blob.pcrs)?;
let mut t = Buf::new();
t.u32(object);
// The authorisation area is the POLICY session, not a password:
// empty nonce and empty HMAC, because the object carries no auth
// value and the policy contains no PolicyAuthValue.
t.u32(9);
t.u32(session);
t.empty2b(); // nonceCaller
t.u8(0x01); // continueSession — this module flushes explicitly
t.empty2b(); // hmac
let body = self.transmit("Unseal", Tpm::command(TPM_ST_SESSIONS, CC_UNSEAL, t))?;
let mut r = Reader::new(&body, "Unseal");
let _parameter_size = r.u32()?;
Ok::<Vec<u8>, TpmError>(r.tpm2b()?.to_vec())
})();
self.flush_quietly(session);
unsealed
})();
self.flush_quietly(object);
Ok(Secret(out?))
}
/// `TPM2_Load` — bring a sealed object into the chip under its parent.
fn load(&mut self, parent: u32, blob: &SealedBlob) -> Result<u32, TpmError> {
let mut t = Buf::new();
t.u32(parent);
t.password_auth();
t.tpm2b(&blob.private);
t.tpm2b(&blob.public);
let body = self.transmit("Load", Tpm::command(TPM_ST_SESSIONS, CC_LOAD, t))?;
let mut r = Reader::new(&body, "Load");
r.u32()
}
// ── attestation ─────────────────────────────────────────────────────────
/// **Derive this machine's attestation key.** ECC P-256, ECDSA-SHA256,
/// `restricted | sign`, primary on the owner hierarchy.
///
/// Deterministic in exactly the way the SRK is: the template plus the
/// chip's own hierarchy seed produce the same key on every boot of this
/// machine, and no other chip can produce it. That is what lets the hub
/// *pin* the public point once, at enrolment, and treat every later quote
/// signed by it as coming from the same physical machine — with no key
/// material stored anywhere and nothing to steal off the disk.
///
/// # What this is NOT
///
/// It is not an EK-certified AK. A full TCG enrolment proves the AK lives
/// in a genuine TPM by `MakeCredential`/`ActivateCredential` against the
/// manufacturer-certified endorsement key. This does not, so the hub's
/// trust in the point is **trust on first sight**: it learns the AK in the
/// same operator-carried ceremony that hands it the WireGuard public key,
/// and from then on it can tell "the same machine as before" from "someone
/// else". Saying that plainly is the point; a comment claiming otherwise
/// would be the lie.
pub fn attestation_key(&mut self) -> Result<AttestationKey, TpmError> {
let mut public = Buf::new();
public.u16(ALG_ECC);
public.u16(ALG_SHA256); // nameAlg
public.u32(AK_ATTRS);
public.empty2b(); // authPolicy: none — userWithAuth + the empty auth value
// TPMS_ECC_PARMS. A signing key carries NO symmetric algorithm; the SRK
// does because it is a restricted DECRYPT parent and has to wrap.
public.u16(ALG_NULL); // symmetric.algorithm
public.u16(ALG_ECDSA); // scheme.scheme
public.u16(ALG_SHA256); // scheme.details.hashAlg
public.u16(ECC_NIST_P256); // curveID
public.u16(ALG_NULL); // kdf
public.empty2b(); // unique.x — the TPM fills it in
public.empty2b(); // unique.y
let public = public.into_vec();
let (handle, out_public) = self.create_primary(&public, "CreatePrimary(AK)")?;
match parse_ecc_public(&out_public) {
Ok(point) => Ok(AttestationKey { handle, public: point }),
Err(e) => {
// A handle that cannot be described is still a handle the chip
// is holding; leaking it would exhaust the object slots on a
// machine that quotes every boot.
self.flush_quietly(handle);
Err(e)
}
}
}
/// **`TPM2_Quote`** — the chip signs *(these PCRs are in this state, and
/// here is the caller's qualifying data)* with the attestation key.
///
/// `qualifying_data` is the verifier's anti-replay handle: it goes into the
/// signed structure as `extraData` and comes back out of it, so a quote
/// that does not carry the challenge the hub issued is a recording of an
/// older one. [`crate::attest`] puts a hash of the hub's nonce **and the
/// appliance's WireGuard public key** there, which is what turns "some
/// machine has a TPM" into "this key belongs to a machine in this state".
///
/// The scheme sent is `TPM_ALG_NULL`, meaning *use the key's own* — the AK
/// template fixes ECDSA-SHA256, so a caller cannot downgrade the hash by
/// asking for a weaker one.
pub fn quote(
&mut self,
ak: &AttestationKey,
qualifying_data: &[u8],
sel: &PcrSelection,
) -> Result<QuoteOutput, TpmError> {
if sel.is_empty() {
return Err(TpmError::Malformed {
command: "Quote",
reason: "the PCR selection is empty — a quote over no PCR says nothing about \
the boot state, which is the only thing a quote is for"
.into(),
});
}
if qualifying_data.is_empty() || qualifying_data.len() > 64 {
return Err(TpmError::Malformed {
command: "Quote",
reason: format!(
"qualifyingData is {} bytes; a challenge is 1–64, and an EMPTY one would \
make every quote this machine ever produced interchangeable — which is \
the replay this field exists to stop",
qualifying_data.len()
),
});
}
let mut t = Buf::new();
t.u32(ak.handle);
t.password_auth();
t.tpm2b(qualifying_data);
t.u16(ALG_NULL); // inScheme: use the key's own scheme
sel.marshal(&mut t);
let body = self.transmit("Quote", Tpm::command(TPM_ST_SESSIONS, CC_QUOTE, t))?;
let mut r = Reader::new(&body, "Quote");
let _parameter_size = r.u32()?;
let quoted = r.tpm2b()?.to_vec();
// TPMT_SIGNATURE
let sig_alg = r.u16()?;
if sig_alg != ALG_ECDSA {
return r.err(format!(
"the chip signed with algorithm {sig_alg:#06x}, not ECDSA — this client can \
only hand a verifier an ECDSA signature"
));
}
let hash_alg = r.u16()?;
if hash_alg != ALG_SHA256 {
return r.err(format!(
"the quote is hashed with {hash_alg:#06x}, not SHA-256 — a verifier that \
assumed SHA-256 would reject every signature and never say why"
));
}
let sig_r = pad32(r.tpm2b()?, "signatureR")?;
let sig_s = pad32(r.tpm2b()?, "signatureS")?;
Ok(QuoteOutput { quoted, sig_r, sig_s })
}
/// Derive the AK, quote, and flush it again — the one-shot every caller
/// outside a test wants, because the AK is re-derived rather than stored.
pub fn quote_now(
&mut self,
qualifying_data: &[u8],
sel: &PcrSelection,
) -> Result<(AkPublic, QuoteOutput), TpmError> {
let ak = self.attestation_key()?;
let out = self.quote(&ak, qualifying_data, sel);
self.flush_quietly(ak.handle);
Ok((ak.public, out?))
}
// ── NV monotonic counters ───────────────────────────────────────────────
//
// Not used by the seal or the quote. They are here because a rollback floor
// needs a number that CANNOT be decremented by anyone holding the disk, and
// the chip is the only thing on this machine that can promise that. The
// alternative — a version in a file — is exactly the record an attacker who
// rolls the image back also rolls back.
/// `TPM2_NV_DefineSpace` for an 8-byte **monotonic counter** at `index`,
/// owner-readable and owner-writable, `noDA`.
///
/// A freshly defined counter cannot be read until it has been incremented
/// once (`TPM_RC_NV_UNINITIALIZED`), so this does that first increment
/// itself — a "defined" counter that refuses to be read is a trap for the
/// next caller, not a state worth exposing.
///
/// Defining an index that already exists is `TPM_RC_NV_DEFINED`, and it is
/// RETURNED rather than swallowed: quietly accepting it would let a caller
/// believe it had created a fresh counter when it had adopted somebody
/// else's, at whatever value that one happens to hold.
pub fn nv_define_counter(&mut self, index: u32) -> Result<(), TpmError> {
self.nv_define(index, NV_COUNTER_ATTRS, 8)?;
self.nv_increment(index)
}
/// `TPM2_NV_DefineSpace` with the attributes and size spelled out. The
/// general door; [`Tpm::nv_define_counter`] is the one shape this crate
/// uses, and the reason this is public is that a proof needs to define an
/// index that is *not* a counter in order to watch
/// [`Tpm::nv_ensure_counter`] refuse to adopt it.
pub fn nv_define(&mut self, index: u32, attrs: u32, size: u16) -> Result<(), TpmError> {
let mut nv_public = Buf::new();
nv_public.u32(index);
nv_public.u16(ALG_SHA256); // nameAlg
nv_public.u32(attrs);
nv_public.empty2b(); // authPolicy
nv_public.u16(size);
let nv_public = nv_public.into_vec();
let mut t = Buf::new();
t.u32(TPM_RH_OWNER);
t.password_auth();
t.empty2b(); // auth: the index carries no auth value of its own
t.tpm2b(&nv_public);
self.transmit("NV_DefineSpace", Tpm::command(TPM_ST_SESSIONS, CC_NV_DEFINE_SPACE, t))?;
Ok(())
}
/// **The idempotent door a rollback floor actually needs**: define the
/// counter if this machine has none, adopt it if it already has one, and
/// hand back its value either way.
///
/// A floor is defined by one install and read by every boot afterwards, so
/// "already defined" is the NORMAL case, not an error. But adopting an
/// index because it exists is exactly where this kind of code goes blind:
/// an index at the same address could have been defined by anything, as
/// **ordinary** NV storage that is freely writable to any value — and a
/// floor stored there can be walked backwards by whoever holds the machine,
/// which is the entire property it was supposed to have.
///
/// So an existing index is READ BACK ([`Tpm::nv_read_public`]) and its
/// attributes checked before a single value is believed. Wrong type, wrong
/// size, or no owner access, and this refuses by name rather than returning
/// a number that looks exactly like a working floor.
pub fn nv_ensure_counter(&mut self, index: u32) -> Result<NvCounter, TpmError> {
match self.nv_read_public(index) {
Ok(public) => {
// Adopting: prove it is what a floor needs BEFORE trusting it.
let nt = (public.attributes >> 4) & 0xF;
if nt != 1 {
return Err(TpmError::Malformed {
command: "NV_ReadPublic",
reason: format!(
"NV index {index:#010x} already exists but its type is {nt} \
(TPM_NT_COUNTER is 1) — this is ORDINARY storage, writable to any \
value, so a rollback floor kept here could be walked backwards by \
anyone holding the machine. Refusing to adopt it as a counter"
),
});
}
if public.data_size != 8 {
return Err(TpmError::Malformed {
command: "NV_ReadPublic",
reason: format!(
"NV index {index:#010x} is {} bytes, not the 8 a counter is",
public.data_size
),
});
}
if public.attributes & 0x0002_0000 == 0 || public.attributes & 0x0000_0002 == 0 {
return Err(TpmError::Malformed {
command: "NV_ReadPublic",
reason: format!(
"NV index {index:#010x} is a counter but not owner-readable and \
owner-writable (attributes {:#010x}) — this build authorises with \
the owner hierarchy and could not step it",
public.attributes
),
});
}
let value = self.nv_read_counter_initialised(index)?;
Ok(NvCounter { index, value, created: false })
}
// TPM_RC_HANDLE is what a chip says about an index that was never
// defined. Every other refusal is propagated: a chip in lockout, or
// a client bug, must not look like a fresh machine.
Err(e) if e.is_undefined_nv_index() => {
self.nv_define(index, NV_COUNTER_ATTRS, 8)?;
self.nv_increment(index)?;
let value = self.nv_read_counter(index)?;
Ok(NvCounter { index, value, created: true })
}
Err(e) => Err(e),
}
}
/// [`Tpm::nv_read_counter`], stepping the counter once first if the chip
/// says it has never been written.
///
/// A counter that was defined and then lost power before its first
/// increment answers `TPM_RC_NV_UNINITIALIZED` for ever until something
/// increments it. Treating that as a hard failure would strand a machine on
/// a state that a single command fixes and that no operator can diagnose.
fn nv_read_counter_initialised(&mut self, index: u32) -> Result<u64, TpmError> {
match self.nv_read_counter(index) {
Ok(v) => Ok(v),
Err(TpmError::Rc { rc, .. }) if rc_base(rc) == 0x14A => {
self.nv_increment(index)?;
self.nv_read_counter(index)
}
Err(e) => Err(e),
}
}
/// `TPM2_NV_ReadPublic` — the index's declared shape, with no authorisation
/// at all. This is how "an index exists here" is told apart from "an index
/// that can hold a monotonic floor exists here".
pub fn nv_read_public(&mut self, index: u32) -> Result<NvPublic, TpmError> {
let mut t = Buf::new();
t.u32(index);
let body =
self.transmit("NV_ReadPublic", Tpm::command(TPM_ST_NO_SESSIONS, CC_NV_READ_PUBLIC, t))?;
let mut r = Reader::new(&body, "NV_ReadPublic");
let nv_public = r.tpm2b()?;
let mut p = Reader::new(nv_public, "TPMS_NV_PUBLIC");
let found_index = p.u32()?;
let name_alg = p.u16()?;
let attributes = p.u32()?;
let _auth_policy = p.tpm2b()?;
let data_size = p.u16()?;
if found_index != index {
return p.err(format!(
"asked about NV index {index:#010x} and the chip described {found_index:#010x}"
));
}
Ok(NvPublic { index, name_alg, attributes, data_size })
}
/// `TPM2_NV_Increment` — step the counter. The chip guarantees this is the
/// only direction it moves, across power cycles, for the life of the index.
pub fn nv_increment(&mut self, index: u32) -> Result<(), TpmError> {
let mut t = Buf::new();
t.u32(TPM_RH_OWNER); // authHandle
t.u32(index); // nvIndex
t.password_auth();
self.transmit("NV_Increment", Tpm::command(TPM_ST_SESSIONS, CC_NV_INCREMENT, t))?;
Ok(())
}
/// `TPM2_NV_Read` of the 8 counter bytes, big-endian.
pub fn nv_read_counter(&mut self, index: u32) -> Result<u64, TpmError> {
let mut t = Buf::new();
t.u32(TPM_RH_OWNER); // authHandle
t.u32(index); // nvIndex
t.password_auth();
t.u16(8); // size
t.u16(0); // offset
let body = self.transmit("NV_Read", Tpm::command(TPM_ST_SESSIONS, CC_NV_READ, t))?;
let mut r = Reader::new(&body, "NV_Read");
let _parameter_size = r.u32()?;
let data = r.tpm2b()?;
let bytes: [u8; 8] = data.try_into().map_err(|_| TpmError::Malformed {
command: "NV_Read",
reason: format!("a counter is 8 bytes; the index returned {}", data.len()),
})?;
Ok(u64::from_be_bytes(bytes))
}
/// `TPM2_NV_UndefineSpace` — remove the index. This DESTROYS the floor, so
/// it exists for tests and for a deliberate operator reset, never for a
/// boot path.
pub fn nv_undefine(&mut self, index: u32) -> Result<(), TpmError> {
let mut t = Buf::new();
t.u32(TPM_RH_OWNER);
t.u32(index);
t.password_auth();
self.transmit("NV_UndefineSpace", Tpm::command(TPM_ST_SESSIONS, CC_NV_UNDEFINE_SPACE, t))?;
Ok(())
}
}
/// Left-pad a curve parameter the chip may have handed back with leading
/// zeroes stripped, refusing anything that cannot be a P-256 scalar.
fn pad32(bytes: &[u8], field: &'static str) -> Result<[u8; 32], TpmError> {
if bytes.len() > 32 || bytes.is_empty() {
return Err(TpmError::Malformed {
command: "Quote",
reason: format!("{field} is {} bytes, which is not a P-256 scalar", bytes.len()),
});
}
let mut out = [0u8; 32];
out[32 - bytes.len()..].copy_from_slice(bytes);
Ok(out)
}
/// Parse a `TPMT_PUBLIC` carrying an ECC key and hand back its point.
///
/// Total and bounds-checked like everything else here that reads bytes off the
/// wire: this parses a response, and a chip or an emulator that answers short
/// must produce a named refusal rather than a panic.
fn parse_ecc_public(public: &[u8]) -> Result<AkPublic, TpmError> {
let mut r = Reader::new(public, "CreatePrimary outPublic");
let kind = r.u16()?;
if kind != ALG_ECC {
return r.err(format!("the primary is type {kind:#06x}, not ECC"));
}
let _name_alg = r.u16()?;
let _attrs = r.u32()?;
let _auth_policy = r.tpm2b()?;
// TPMS_ECC_PARMS — the symmetric field is a TPMT_SYM_DEF_OBJECT, which
// carries keyBits and mode ONLY when its algorithm is not NULL. The AK has
// none and the SRK has AES-128-CFB, so a parser that assumed either shape
// would read the point out of the wrong offset on the other.
let symmetric = r.u16()?;
if symmetric != ALG_NULL {
let _key_bits = r.u16()?;
let _mode = r.u16()?;
}
let scheme = r.u16()?;
match scheme {
ALG_NULL => {}
ALG_ECDSA => {
let _hash = r.u16()?;
}
other => {
return r.err(format!(
"signing scheme {other:#06x} is not one this client can hand a verifier"
))
}
}
let curve = r.u16()?;
if curve != ECC_NIST_P256 {
return r.err(format!("curve {curve:#06x} is not NIST P-256"));
}
let kdf = r.u16()?;
if kdf != ALG_NULL {
let _hash = r.u16()?;
}
let x = pad32(r.tpm2b()?, "unique.x")?;
let y = pad32(r.tpm2b()?, "unique.y")?;
Ok(AkPublic { x, y })
}
/// Render a digest as lowercase hex — console lines and log records only.
pub fn hex(bytes: &[u8]) -> String {
bytes.iter().map(|b| format!("{b:02x}")).collect()
}
#[cfg(test)]
mod marshalling_tests {
use super::*;
/// The wire encoding of the pieces every command is built from. These run
/// with no TPM anywhere: a marshalling bug is a bug on a machine that has
/// no chip to catch it.
#[test]
fn the_primitives_marshal_big_endian_and_the_2b_carries_its_length() {
let mut b = Buf::new();
b.u8(0x01).u16(0x0203).u32(0x0405_0607);
assert_eq!(b.into_vec(), vec![0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07]);
let mut b = Buf::new();
b.tpm2b(&[0xAA, 0xBB, 0xCC]);
assert_eq!(b.into_vec(), vec![0x00, 0x03, 0xAA, 0xBB, 0xCC]);
let mut b = Buf::new();
b.empty2b();
assert_eq!(b.into_vec(), vec![0x00, 0x00]);
}
/// The nine-byte password authorisation area, byte for byte. A tenth byte
/// here is a `TPM_RC_AUTHSIZE` that looks like a command-code bug.
#[test]
fn the_password_auth_area_is_exactly_nine_bytes_of_declared_shape() {
let mut b = Buf::new();
b.password_auth();
let got = b.into_vec();
assert_eq!(
got,
vec![
0x00, 0x00, 0x00, 0x09, // authSize = 9
0x40, 0x00, 0x00, 0x09, // TPM_RS_PW
0x00, 0x00, // nonce: empty
0x01, // continueSession
0x00, 0x00, // hmac: empty
]
);
assert_eq!(got.len(), 13, "4 bytes of size plus the 9 it declares");
}
/// The command header: tag, then the TOTAL size including the header, then
/// the code. A size that counts only the tail is the classic first bug.
#[test]
fn the_command_header_counts_itself_in_the_size() {
let mut tail = Buf::new();
tail.u16(32);
let cmd = Tpm::command(TPM_ST_NO_SESSIONS, CC_GET_RANDOM, tail);
assert_eq!(cmd[0..2], [0x80, 0x01], "TPM_ST_NO_SESSIONS");
assert_eq!(cmd[2..6], [0, 0, 0, 12], "10 header + 2 tail");
assert_eq!(cmd[6..10], [0x00, 0x00, 0x01, 0x7B], "TPM2_CC_GetRandom");
assert_eq!(cmd.len(), 12, "the declared size IS the buffer length");
}
/// `TPML_PCR_SELECTION`: one selection, `sizeofSelect = 3`, little-endian
/// BITS inside big-endian bytes — PCR 0 is bit 0 of byte 0, PCR 7 is bit 7
/// of byte 0, PCR 8 is bit 0 of byte 1.
#[test]
fn a_pcr_selection_marshals_as_a_three_byte_bitmap() {
let mut b = Buf::new();
PcrSelection::DEFAULT.marshal(&mut b);
assert_eq!(
b.into_vec(),
vec![
0x00, 0x00, 0x00, 0x01, // count = 1
0x00, 0x0B, // sha256
0x03, // sizeofSelect
0x81, 0x00, 0x00, // PCR 0 (bit 0) and PCR 7 (bit 7)
]
);
let mut b = Buf::new();
PcrSelection { alg: ALG_SHA256, mask: 1 << 8 }.marshal(&mut b);
assert_eq!(b.into_vec()[7..], [0x00, 0x01, 0x00], "PCR 8 is bit 0 of the SECOND byte");
let mut b = Buf::new();
PcrSelection { alg: ALG_SHA256, mask: 1 << 23 }.marshal(&mut b);
assert_eq!(b.into_vec()[7..], [0x00, 0x00, 0x80], "PCR 23 is bit 7 of the THIRD byte");
}
/// The reader refuses past its end instead of panicking. Its input is a
/// device response; a chip or an emulator that answers short must produce a
/// named refusal, not a slice index panic in PID 1.
#[test]
fn the_reader_refuses_a_short_response_by_name() {
let mut r = Reader::new(&[0x00, 0x01], "TestCommand");
assert!(r.u16().is_ok());
let e = r.u32().unwrap_err();
let msg = e.to_string();
assert!(msg.contains("TestCommand"), "the refusal names the command: {msg}");
assert!(msg.contains("malformed"), "{msg}");
// A TPM2B whose length runs past the buffer is refused, not sliced.
let mut r = Reader::new(&[0xFF, 0xFF, 0x01], "TestCommand");
assert!(r.tpm2b().is_err(), "a 65535-byte field in a 3-byte buffer");
// And the control: a well-formed one reads.
let mut r = Reader::new(&[0x00, 0x02, 0xAB, 0xCD], "TestCommand");
assert_eq!(r.tpm2b().unwrap(), &[0xAB, 0xCD]);
}
/// The selection grammar the kernel command line can carry. This string
/// arrives off the plaintext ESP, so every refusal is by name.
#[test]
fn the_pcr_selection_grammar_refuses_by_name() {
assert_eq!(PcrSelection::parse("0,7").unwrap(), PcrSelection::DEFAULT);
assert_eq!(PcrSelection::parse("sha256:0,7").unwrap(), PcrSelection::DEFAULT);
assert_eq!(PcrSelection::parse(" sha256 : 0 , 7 ").unwrap(), PcrSelection::DEFAULT);
assert_eq!(PcrSelection::parse("sha1:0").unwrap().alg, ALG_SHA1);
assert_eq!(PcrSelection::parse("23").unwrap().mask, 1 << 23);
for (bad, must_say) in [
("", "no PCR indices"),
("sha256:", "no PCR indices"),
("24", "out of range"),
("99", "out of range"),
("sha384:0", "not a PCR bank"),
("0,x", "not a PCR index"),
("-1", "not a PCR index"),
] {
let e = PcrSelection::parse(bad).unwrap_err();
assert!(e.contains(must_say), "{bad:?} must say {must_say:?}, said: {e}");
}
// The control: the guard above is not simply "refuse everything".
assert!(PcrSelection::parse("0,2,4,7").is_ok());
}
/// Display round-trips through the parser — one spelling, both directions.
#[test]
fn a_selection_renders_the_way_it_parses() {
for s in ["sha256:0,7", "sha256:0", "sha1:0,1,2,3", "sha256:0,2,4,7,23"] {
let sel = PcrSelection::parse(s).unwrap();
assert_eq!(sel.to_string(), s, "the rendering is the parser's input");
}
}
/// The on-disk blob survives a round trip, and its magic/version/mask land
/// where the layout comment says.
#[test]
fn a_sealed_blob_round_trips_and_its_header_is_where_it_says() {
let blob = SealedBlob {
pcrs: PcrSelection::DEFAULT,
private: vec![0x11; 70],
public: vec![0x22; 48],
};
let bytes = blob.encode();
assert_eq!(&bytes[..8], SEAL_MAGIC);
assert_eq!(u16::from_be_bytes([bytes[8], bytes[9]]), SEAL_VERSION);
assert_eq!(u16::from_be_bytes([bytes[10], bytes[11]]), ALG_SHA256);
assert_eq!(u32::from_be_bytes(bytes[12..16].try_into().unwrap()), 0x81);
assert_eq!(bytes.len(), 48 + 2 + 70 + 2 + 48);
assert_eq!(SealedBlob::decode(&bytes).unwrap(), blob);
}
/// **A corrupt sealed blob is refused BY NAME, never a panic.**
///
/// `korp-start` reads this file off a FAT partition as PID 1, where a panic
/// is a kernel panic with no line saying which record was bad — the exact
/// class fixed in `loader::decode_hex32` (edda 1b95cd4) hours before this
/// module was written. Every case below is a file an attacker with the disk
/// can write.
#[test]
fn a_corrupt_sealed_blob_is_refused_by_name_and_never_panics() {
let good = SealedBlob {
pcrs: PcrSelection::DEFAULT,
private: vec![0x11; 70],
public: vec![0x22; 48],
}
.encode();
// Empty, short, and not-a-seal.
assert!(matches!(SealedBlob::decode(&[]), Err(BlobError::NotASeal { .. })));
assert!(matches!(SealedBlob::decode(b"KORP"), Err(BlobError::NotASeal { .. })));
assert!(matches!(
SealedBlob::decode(b"NOTASEAL and then some"),
Err(BlobError::NotASeal { .. })
));
// The magic alone, with no header behind it.
assert!(matches!(SealedBlob::decode(SEAL_MAGIC), Err(BlobError::Truncated { .. })));
// A wrong version is refused before any length field is believed.
let mut wrong_version = good.clone();
wrong_version[9] = 9;
assert_eq!(SealedBlob::decode(&wrong_version), Err(BlobError::Version { found: 9 }));
// One flipped payload bit fails the checksum.
let mut flipped = good.clone();
let last = flipped.len() - 1;
flipped[last] ^= 0x01;
assert_eq!(SealedBlob::decode(&flipped), Err(BlobError::Checksum));
// A length field claiming more than the file holds — with the checksum
// recomputed, so the ONLY thing standing between this and a panic is
// the bounds check.
let mut body = Buf::new();
body.u16(0xFFFF); // private claims 65535 bytes
body.raw(&[0u8; 4]);
let body = body.into_vec();
let mut hostile = Buf::new();
hostile.raw(SEAL_MAGIC);
hostile.u16(SEAL_VERSION);
hostile.u16(ALG_SHA256);
hostile.u32(0x81);
hostile.raw(blake3::hash(&body).as_bytes());
hostile.raw(&body);
let e = SealedBlob::decode(&hostile.into_vec()).unwrap_err();
assert!(
matches!(e, BlobError::Truncated { field: "private", wanted: 65535, .. }),
"a hostile length must be REFUSED, got {e}"
);
// A seal declaring NO PCRs — the file shape of "unseal me in any boot
// state", which is the whole failure this mechanism exists to prevent.
let empty_mask = SealedBlob {
pcrs: PcrSelection { alg: ALG_SHA256, mask: 0 },
private: vec![1],
public: vec![2],
}
.encode();
assert!(matches!(
SealedBlob::decode(&empty_mask),
Err(BlobError::BadSelection { .. })
));
// Every refusal above renders a sentence naming the problem.
for msg in [
BlobError::NotASeal { found: "'x'".into() }.to_string(),
BlobError::Version { found: 9 }.to_string(),
BlobError::Truncated { field: "public", wanted: 5, available: 1 }.to_string(),
BlobError::Checksum.to_string(),
BlobError::BadSelection { reason: "why".into() }.to_string(),
] {
assert!(msg.len() > 30, "a refusal is a sentence, not a code: {msg}");
}
// The control: the good bytes still decode, so the loop above was the
// guard and not a decoder that refuses everything.
assert!(SealedBlob::decode(&good).is_ok());
}
/// Response-code decoding, including the format-1 decoration that makes a
/// policy failure look like a different number every time.
#[test]
fn response_codes_are_named_through_their_format_one_decoration() {
// TPM_RC_POLICY_FAIL, plain and with a session/parameter number in the
// high nibbles — the chip decorates format-1 codes with WHERE the fault
// was, and a client that compares raw values misses its own refusal.
assert_eq!(rc_name(0x0000_099D), "TPM_RC_POLICY_FAIL");
assert_eq!(rc_name(0x0000_019D), "TPM_RC_POLICY_FAIL");
assert_eq!(rc_name(0x0000_09D), "TPM_RC_POLICY_FAIL");
assert_eq!(rc_name(0x0000_019F), "TPM_RC_INTEGRITY");
assert_eq!(rc_name(0x0000_0128), "TPM_RC_PCR_CHANGED");
assert_eq!(rc_name(0x0000_0143), "TPM_RC_COMMAND_CODE");
assert_eq!(rc_name(0x0000_0DEAD), "unnamed in this client");
let policy = TpmError::Rc {
command: "Unseal",
rc: 0x0000_099D,
name: rc_name(0x0000_099D),
};
assert!(policy.is_policy_refusal(), "a digest mismatch is the EXPECTED refusal");
assert!(policy.to_string().contains("TPM_RC_POLICY_FAIL"), "{policy}");
assert!(policy.to_string().contains("Unseal"), "the refusal names the command");
let integrity = TpmError::Rc { command: "Load", rc: 0x19F, name: rc_name(0x19F) };
assert!(integrity.is_policy_refusal(), "a foreign blob is a refusal, not a bug");
// …and the control: a genuine wiring bug is NOT reported as a policy
// refusal, or a broken client would look like a secure one.
let wiring = TpmError::Rc { command: "Create", rc: 0x143, name: rc_name(0x143) };
assert!(!wiring.is_policy_refusal(), "TPM_RC_COMMAND_CODE is a bug, not a boot state");
}
/// The sealed object's attributes, spelled out. `userWithAuth` SET here
/// would let anyone unseal by presenting the empty auth value, and the PCR
/// policy would be decoration — a green that could never go red.
#[test]
fn the_sealed_object_attributes_leave_user_with_auth_clear() {
assert_eq!(SEALED_OBJECT_ATTRS, 0x12, "fixedTPM | fixedParent, and nothing else");
assert_eq!(SEALED_OBJECT_ATTRS & 0x0000_0040, 0, "userWithAuth MUST be clear");
assert_eq!(SEALED_OBJECT_ATTRS & 0x0000_0020, 0, "sensitiveDataOrigin: we supply the data");
// The SRK's, for contrast: a restricted decryption parent.
assert_eq!(SRK_ATTRS & 0x0001_0000, 0x0001_0000, "restricted");
assert_eq!(SRK_ATTRS & 0x0002_0000, 0x0002_0000, "decrypt");
assert_eq!(SRK_ATTRS & 0x0000_0040, 0x0000_0040, "the SRK IS userWithAuth");
}
/// **The attestation key's attributes, spelled out.**
///
/// `restricted` CLEAR would leave a key that signs whatever it is handed —
/// including a `TPMS_ATTEST` an attacker composed byte by byte — and every
/// signature check in `attest.rs` would still pass. It is the one bit whose
/// absence nothing downstream can detect.
#[test]
fn the_attestation_key_is_restricted_and_signs_and_never_leaves_the_chip() {
assert_eq!(AK_ATTRS & 0x0001_0000, 0x0001_0000, "restricted");
assert_eq!(AK_ATTRS & 0x0004_0000, 0x0004_0000, "sign");
assert_eq!(AK_ATTRS & 0x0002_0000, 0, "an AK must NOT be a decryption key");
assert_eq!(AK_ATTRS & 0x0000_0002, 0x0000_0002, "fixedTPM — no copy off this chip");
assert_eq!(AK_ATTRS & 0x0000_0010, 0x0000_0010, "fixedParent");
assert_eq!(AK_ATTRS & 0x0000_0040, 0x0000_0040, "userWithAuth — the empty auth signs");
// The AK and the SRK differ in EXACTLY the two role bits. Identical
// templates would make the chip derive the same key for both, and the
// machine would be signing attestations with its storage root.
assert_eq!(AK_ATTRS ^ SRK_ATTRS, 0x0006_0000, "sign vs decrypt, and nothing else");
}
/// The NV counter's attributes. `TPM_NT_COUNTER` in bits 4–7 is what makes
/// the index monotonic; ordinary NV storage would be freely writable to any
/// value, i.e. a rollback floor an attacker simply lowers.
#[test]
fn the_nv_counter_is_declared_a_counter_and_not_ordinary_storage() {
assert_eq!((NV_COUNTER_ATTRS >> 4) & 0xF, 1, "TPM_NT_COUNTER");
assert_eq!(NV_COUNTER_ATTRS & 0x0000_0002, 0x0000_0002, "OWNERWRITE");
assert_eq!(NV_COUNTER_ATTRS & 0x0002_0000, 0x0002_0000, "OWNERREAD");
assert_eq!(NV_COUNTER_ATTRS & 0x0200_0000, 0x0200_0000, "NO_DA");
assert_eq!(NV_COUNTER_ATTRS & 0x4000_0000, 0, "not PLATFORMCREATE — owner defines it");
}
/// The AK's public point survives the spelling an operator carries to the
/// hub, and every malformed spelling is refused by name rather than
/// truncated into a key that would verify nothing and say nothing.
#[test]
fn an_attestation_key_round_trips_through_its_operator_spelling() {
let ak = AkPublic { x: [0x11; 32], y: [0x22; 32] };
assert_eq!(ak.sec1()[0], 0x04, "SEC1 uncompressed");
assert_eq!(ak.to_hex().len(), 130);
assert_eq!(AkPublic::from_hex(&ak.to_hex()).unwrap(), ak);
assert_eq!(AkPublic::from_sec1(&ak.sec1()).unwrap(), ak);
assert_eq!(AkPublic::from_hex(&format!(" {}\n", ak.to_hex())).unwrap(), ak);
for bad in ["", "04", &"a".repeat(129), &"z".repeat(130)] {
let e = AkPublic::from_hex(bad).unwrap_err();
assert!(e.contains("130 hex"), "{bad:?} refused as: {e}");
}
// 65 bytes but NOT uncompressed: a verifier handed a compressed point
// would reject every genuine signature from that machine.
let mut compressed = ak.sec1();
compressed[0] = 0x02;
assert!(AkPublic::from_sec1(&compressed).unwrap_err().contains("0x04"));
}
/// `TPMT_PUBLIC` parsing, both shapes: the AK's (no symmetric, ECDSA
/// scheme) and the SRK's (AES-CFB symmetric, NULL scheme). One reader, two
/// layouts — a parser that assumed either would read the point out of the
/// wrong offset on the other and hand a verifier 32 bytes of another field.
#[test]
fn the_public_area_parser_finds_the_point_in_both_key_shapes() {
let x = [0xAB; 32];
let y = [0xCD; 32];
let mut ak = Buf::new();
ak.u16(ALG_ECC);
ak.u16(ALG_SHA256);
ak.u32(AK_ATTRS);
ak.empty2b();
ak.u16(ALG_NULL); // symmetric
ak.u16(ALG_ECDSA); // scheme
ak.u16(ALG_SHA256); // hash
ak.u16(ECC_NIST_P256);
ak.u16(ALG_NULL); // kdf
ak.tpm2b(&x);
ak.tpm2b(&y);
assert_eq!(parse_ecc_public(&ak.into_vec()).unwrap(), AkPublic { x, y });
let mut srk = Buf::new();
srk.u16(ALG_ECC);
srk.u16(ALG_SHA256);
srk.u32(SRK_ATTRS);
srk.empty2b();
srk.u16(ALG_AES); // symmetric — three more fields follow
srk.u16(128);
srk.u16(ALG_CFB);
srk.u16(ALG_NULL); // scheme
srk.u16(ECC_NIST_P256);
srk.u16(ALG_NULL); // kdf
srk.tpm2b(&x);
srk.tpm2b(&y);
assert_eq!(parse_ecc_public(&srk.into_vec()).unwrap(), AkPublic { x, y });
// A short buffer is a NAMED refusal, never a panic.
let mut short = Buf::new();
short.u16(ALG_ECC);
short.u16(ALG_SHA256);
let e = parse_ecc_public(&short.into_vec()).unwrap_err();
assert!(e.to_string().contains("malformed"), "{e}");
// An RSA public area is refused by name rather than mis-read as ECC.
let mut rsa = Buf::new();
rsa.u16(0x0001); // TPM_ALG_RSA
let e = parse_ecc_public(&rsa.into_vec()).unwrap_err();
assert!(e.to_string().contains("not ECC"), "{e}");
}
/// A curve parameter the chip stripped leading zeroes from is LEFT-padded,
/// not right-padded. Right-padding multiplies the scalar by 256^k, and the
/// signature would fail on roughly one quote in 256 — intermittently,
/// which is the worst way for a verifier to be wrong.
#[test]
fn a_short_curve_parameter_is_left_padded() {
assert_eq!(pad32(&[0xFF], "r").unwrap()[31], 0xFF);
assert_eq!(pad32(&[0xFF], "r").unwrap()[0], 0x00);
assert_eq!(pad32(&[7u8; 32], "r").unwrap(), [7u8; 32]);
assert!(pad32(&[], "r").is_err(), "an empty scalar is not a signature");
assert!(pad32(&[0u8; 33], "r").is_err(), "33 bytes is not a P-256 scalar");
}
/// A `Secret` redacts itself in `Debug`. A derived impl would put the
/// WireGuard private key one `{:?}` away from every log line.
#[test]
fn a_secret_never_prints_itself() {
let s = Secret(b"this is the private key".to_vec());
let rendered = format!("{s:?}");
assert!(!rendered.contains("private key"), "{rendered}");
assert!(rendered.contains("redacted"), "{rendered}");
assert_eq!(s.len(), 23);
}
}