#![cfg(target_os = "linux")]
use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use crate::error::Error;
const PRIMARY_HANDLE: &str = "0x81010001";
struct PathCleanup<'a> {
paths: &'a [&'a Path],
}
impl Drop for PathCleanup<'_> {
fn drop(&mut self) {
for p in self.paths {
let _ = std::fs::remove_file(p);
}
}
}
pub struct Tpm2Keystore {
workdir: PathBuf,
}
impl Tpm2Keystore {
pub fn try_new() -> Option<Self> {
let device_present = Path::new("/dev/tpmrm0").exists() || Path::new("/dev/tpm0").exists();
if !device_present {
return None;
}
if !tool_available("tpm2_getcap") {
return None;
}
if !tool_available("tpm2_createprimary") {
return None;
}
if !tool_available("tpm2_create") {
return None;
}
if !tool_available("tpm2_load") {
return None;
}
if !tool_available("tpm2_unseal") {
return None;
}
if !tool_available("tpm2_evictcontrol") {
return None;
}
let ok = Command::new("tpm2_getcap")
.arg("properties-fixed")
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.is_ok_and(|s| s.success());
if !ok {
return None;
}
let workdir = scratch_dir();
if std::fs::create_dir_all(&workdir).is_err() {
return None;
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(&workdir, std::fs::Permissions::from_mode(0o700));
}
if ensure_primary(&workdir).is_err() {
return None;
}
Some(Self { workdir })
}
pub fn seal(&self, plaintext: &[u8]) -> Result<Vec<u8>, Error> {
let pub_path = self
.workdir
.join(format!("seal-{}.pub", std::process::id()));
let priv_path = self
.workdir
.join(format!("seal-{}.priv", std::process::id()));
let cleanup_paths = [pub_path.as_path(), priv_path.as_path()];
let _cleanup = PathCleanup {
paths: &cleanup_paths,
};
let mut child = Command::new("tpm2_create")
.arg("-C")
.arg(PRIMARY_HANDLE)
.arg("-i")
.arg("-") .arg("-u")
.arg(&pub_path)
.arg("-r")
.arg(&priv_path)
.stdin(Stdio::piped())
.stdout(Stdio::null())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| Error::CryptoFailure(format!("tpm2_create spawn failed: {e}")))?;
if let Some(stdin) = child.stdin.as_mut() {
stdin.write_all(plaintext).map_err(|e| {
Error::CryptoFailure(format!("writing to tpm2_create stdin failed: {e}"))
})?;
}
let output = child
.wait_with_output()
.map_err(|e| Error::CryptoFailure(format!("tpm2_create wait failed: {e}")))?;
if !output.status.success() {
return Err(Error::CryptoFailure(format!(
"tpm2_create failed: {}",
String::from_utf8_lossy(&output.stderr).trim()
)));
}
let pub_bytes = std::fs::read(&pub_path)
.map_err(|e| Error::CryptoFailure(format!("reading sealed pub failed: {e}")))?;
let priv_bytes = std::fs::read(&priv_path)
.map_err(|e| Error::CryptoFailure(format!("reading sealed priv failed: {e}")))?;
Ok(pack_envelope(&pub_bytes, &priv_bytes))
}
pub fn unseal(&self, sealed: &[u8]) -> Result<Vec<u8>, Error> {
let (pub_bytes, priv_bytes) = unpack_envelope(sealed)?;
let pub_path = self
.workdir
.join(format!("unseal-{}.pub", std::process::id()));
let priv_path = self
.workdir
.join(format!("unseal-{}.priv", std::process::id()));
let ctx_path = self
.workdir
.join(format!("unseal-{}.ctx", std::process::id()));
let cleanup_paths = [pub_path.as_path(), priv_path.as_path(), ctx_path.as_path()];
let _cleanup = PathCleanup {
paths: &cleanup_paths,
};
write_owner_only(&pub_path, pub_bytes)?;
write_owner_only(&priv_path, priv_bytes)?;
let load = Command::new("tpm2_load")
.arg("-C")
.arg(PRIMARY_HANDLE)
.arg("-u")
.arg(&pub_path)
.arg("-r")
.arg(&priv_path)
.arg("-c")
.arg(&ctx_path)
.stdout(Stdio::null())
.stderr(Stdio::piped())
.output()
.map_err(|e| Error::CryptoFailure(format!("tpm2_load spawn failed: {e}")))?;
if !load.status.success() {
return Err(Error::CryptoFailure(format!(
"tpm2_load failed (different machine, wiped TPM, or corrupted blob): {}",
String::from_utf8_lossy(&load.stderr).trim()
)));
}
let unseal = Command::new("tpm2_unseal")
.arg("-c")
.arg(&ctx_path)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.map_err(|e| Error::CryptoFailure(format!("tpm2_unseal spawn failed: {e}")))?;
if !unseal.status.success() {
return Err(Error::CryptoFailure(format!(
"tpm2_unseal failed: {}",
String::from_utf8_lossy(&unseal.stderr).trim()
)));
}
Ok(unseal.stdout)
}
}
fn ensure_primary(workdir: &Path) -> Result<(), Error> {
let probe = Command::new("tpm2_readpublic")
.arg("-c")
.arg(PRIMARY_HANDLE)
.stdout(Stdio::null())
.stderr(Stdio::null())
.status();
if matches!(probe, Ok(s) if s.success()) {
return Ok(());
}
let primary_ctx = workdir.join("primary.ctx");
let create = Command::new("tpm2_createprimary")
.arg("-C")
.arg("o") .arg("-G")
.arg("ecc") .arg("-c")
.arg(&primary_ctx)
.stdout(Stdio::null())
.stderr(Stdio::piped())
.output()
.map_err(|e| Error::CryptoFailure(format!("tpm2_createprimary spawn failed: {e}")))?;
if !create.status.success() {
return Err(Error::CryptoFailure(format!(
"tpm2_createprimary failed: {}",
String::from_utf8_lossy(&create.stderr).trim()
)));
}
let evict = Command::new("tpm2_evictcontrol")
.arg("-C")
.arg("o")
.arg("-c")
.arg(&primary_ctx)
.arg(PRIMARY_HANDLE)
.stdout(Stdio::null())
.stderr(Stdio::piped())
.output()
.map_err(|e| Error::CryptoFailure(format!("tpm2_evictcontrol spawn failed: {e}")))?;
let _ = std::fs::remove_file(&primary_ctx);
if !evict.status.success() {
return Err(Error::CryptoFailure(format!(
"tpm2_evictcontrol failed: {}",
String::from_utf8_lossy(&evict.stderr).trim()
)));
}
Ok(())
}
fn tool_available(name: &str) -> bool {
Command::new(name)
.arg("--version")
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.is_ok_and(|s| s.success())
}
fn scratch_dir() -> PathBuf {
if let Some(rt) = std::env::var_os("XDG_RUNTIME_DIR") {
return PathBuf::from(rt).join("envseal-tpm");
}
let uid = unsafe { libc::geteuid() };
let mut rng = rand::thread_rng();
let rnd: u64 = rand::Rng::gen(&mut rng);
PathBuf::from(format!("/tmp/envseal-tpm-{uid}-{rnd:016x}"))
}
fn write_owner_only(path: &Path, contents: &[u8]) -> Result<(), Error> {
use std::os::unix::fs::OpenOptionsExt;
let display = path.display();
let mut f = std::fs::OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.mode(0o600)
.open(path)
.map_err(|e| Error::CryptoFailure(format!("opening {display} failed: {e}")))?;
f.write_all(contents)
.map_err(|e| Error::CryptoFailure(format!("writing {display} failed: {e}")))?;
f.sync_all()
.map_err(|e| Error::CryptoFailure(format!("syncing {display} failed: {e}")))?;
Ok(())
}
fn pack_envelope(pub_bytes: &[u8], priv_bytes: &[u8]) -> Vec<u8> {
let mut out = Vec::with_capacity(8 + pub_bytes.len() + priv_bytes.len());
let p_len = u32::try_from(pub_bytes.len()).unwrap_or(u32::MAX);
let r_len = u32::try_from(priv_bytes.len()).unwrap_or(u32::MAX);
out.extend_from_slice(&p_len.to_le_bytes());
out.extend_from_slice(&r_len.to_le_bytes());
out.extend_from_slice(pub_bytes);
out.extend_from_slice(priv_bytes);
out
}
fn unpack_envelope(sealed: &[u8]) -> Result<(&[u8], &[u8]), Error> {
let (p_len_bytes, rest) = sealed.split_first_chunk::<4>().ok_or_else(|| {
Error::CryptoFailure("TPM2 envelope shorter than length header".to_string())
})?;
let (r_len_bytes, body) = rest.split_first_chunk::<4>().ok_or_else(|| {
Error::CryptoFailure("TPM2 envelope shorter than length header".to_string())
})?;
let p_len = u32::from_le_bytes(*p_len_bytes) as usize;
let r_len = u32::from_le_bytes(*r_len_bytes) as usize;
if body.len() != p_len + r_len {
return Err(Error::CryptoFailure(format!(
"TPM2 envelope length mismatch: header says {p_len}+{r_len}, body has {}",
body.len()
)));
}
let (pub_bytes, priv_bytes) = body.split_at(p_len);
Ok((pub_bytes, priv_bytes))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn envelope_roundtrip() {
let pub_b = b"PUB-DATA-HERE";
let priv_b = b"PRIV-DATA-HERE-LONGER";
let env = pack_envelope(pub_b, priv_b);
let (p, r) = unpack_envelope(&env).unwrap();
assert_eq!(p, pub_b);
assert_eq!(r, priv_b);
}
#[test]
fn envelope_rejects_truncated() {
assert!(unpack_envelope(&[0u8; 4]).is_err());
}
#[test]
fn envelope_rejects_length_mismatch() {
let mut env = pack_envelope(b"AAA", b"BB");
env.pop(); assert!(unpack_envelope(&env).is_err());
}
#[test]
fn seal_then_unseal_roundtrips_when_tpm_present() {
let Some(ks) = Tpm2Keystore::try_new() else {
eprintln!("TPM2 not available on this host — skipping");
return;
};
let plaintext = b"the master key wrapped envelope";
let sealed = ks.seal(plaintext).expect("TPM2 seal must succeed");
assert_ne!(sealed.as_slice(), plaintext);
let recovered = ks.unseal(&sealed).expect("TPM2 unseal must succeed");
assert_eq!(recovered, plaintext);
}
}