pub mod init;
use crate::storage::credential::Credential;
use crate::storage::index::{
CredentialCache, CredentialIndexes, CredentialPathInfo, get_credential_path,
load_credential_paths, update_indexes_on_delete, update_indexes_on_write,
};
use crate::storage::{CredentialFilter, CredentialStorage};
use crate::util::{bytes_to_hex, create_secure_dir_all, create_secure_file};
use soft_fido2::Result;
use std::fs::File;
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
use std::str::FromStr;
use std::sync::Mutex;
use std::time::Instant;
use aes_gcm::aead::{Aead, KeyInit, OsRng};
use aes_gcm::{Aes256Gcm, Nonce};
use log::{debug, info};
use rand::RngCore;
use tss_esapi::constants::SessionType;
use tss_esapi::interface_types::algorithm::PublicAlgorithm;
use tss_esapi::interface_types::key_bits::RsaKeyBits;
use tss_esapi::interface_types::{algorithm::HashingAlgorithm, resource_handles::Hierarchy};
use tss_esapi::structures::{KeyedHashScheme, Public, PublicBuilder, PublicKeyedHashParameters};
use tss_esapi::structures::{
PublicKeyRsa, PublicRsaParametersBuilder, RsaExponent, RsaScheme, SymmetricDefinitionObject,
};
use tss_esapi::tss2_esys::{TPM2B_PRIVATE, TPM2B_PUBLIC};
use tss_esapi::{Context, Tcti};
use zeroize::Zeroizing;
pub struct TpmStorageAdapter {
storage_dir: PathBuf,
indexes: CredentialIndexes,
cache: CredentialCache,
iteration_index: usize,
iteration_entries: Vec<PathBuf>,
context: Mutex<Context>,
}
#[derive(serde::Serialize, serde::Deserialize)]
struct SealedBlob {
encrypted_data: Vec<u8>,
nonce: Vec<u8>,
tpm_private: Vec<u8>,
tpm_public: Vec<u8>,
}
impl TpmStorageAdapter {
pub fn new(storage_dir: PathBuf, tcti: Option<String>) -> Result<Self> {
info!("Using TPM 2.0 backend");
info!("Storage path: {}", storage_dir.display());
self::init::ensure_initialized(&storage_dir).map_err(|_| soft_fido2::Error::Other)?;
let tcti_conf = if let Some(ref tcti_str) = tcti {
info!("Using TCTI configuration: {}", tcti_str);
Tcti::from_str(tcti_str).map_err(|e| {
log::error!("Failed to parse TCTI configuration '{}': {}", tcti_str, e);
log::error!("TCTI format examples:");
log::error!(" - Hardware TPM: device:/dev/tpmrm0");
log::error!(" - swtpm socket: swtpm:path=/path/to/socket");
soft_fido2::Error::Other
})?
} else {
info!("Using default TCTI: device:/dev/tpmrm0");
Tcti::Device(Default::default())
};
let mut context = Context::new(tcti_conf).map_err(|e| {
log::error!("Failed to create TPM context: {}", e);
if let Some(ref tcti_str) = tcti {
log::error!("TCTI used: {}", tcti_str);
} else {
log::error!("TCTI used: device:/dev/tpmrm0 (default)");
}
log::error!("Troubleshooting steps:");
log::error!(" 1. Verify TPM device exists: ls -l /dev/tpm*");
log::error!(" 2. Check permissions on TPM device");
soft_fido2::Error::Other
})?;
let session = context
.start_auth_session(
None,
None,
None,
SessionType::Hmac,
SymmetricDefinitionObject::AES_128_CFB.into(),
HashingAlgorithm::Sha256,
)
.map_err(|e| {
log::error!("Failed to start TPM auth session: {}", e);
soft_fido2::Error::Other
})?
.ok_or_else(|| {
log::error!("TPM auth session returned None");
soft_fido2::Error::Other
})?;
context.set_sessions((Some(session), None, None));
info!("TPM context initialized successfully");
let indexes = load_credential_paths(&storage_dir, "tpm").map_err(|e| {
log::error!("Failed to load credential paths: {}", e);
soft_fido2::Error::Other
})?;
let adapter = Self {
storage_dir,
indexes,
cache: Default::default(),
iteration_index: 0,
iteration_entries: Vec::new(),
context: Mutex::new(context),
};
Ok(adapter)
}
fn create_primary_key(&self, context: &mut Context) -> Result<tss_esapi::handles::KeyHandle> {
let object_attributes = tss_esapi::attributes::ObjectAttributesBuilder::new()
.with_fixed_tpm(true)
.with_fixed_parent(true)
.with_sensitive_data_origin(true)
.with_user_with_auth(true)
.with_decrypt(true)
.with_restricted(true)
.build()
.map_err(|e| {
log::error!("Failed to build object attributes: {}", e);
soft_fido2::Error::Other
})?;
let rsa_params = PublicRsaParametersBuilder::new()
.with_symmetric(SymmetricDefinitionObject::AES_128_CFB)
.with_scheme(RsaScheme::Null)
.with_key_bits(RsaKeyBits::Rsa2048)
.with_exponent(RsaExponent::default())
.with_is_signing_key(false)
.with_is_decryption_key(true)
.with_restricted(true)
.build()
.map_err(|e| {
log::error!("Failed to build RSA parameters: {}", e);
soft_fido2::Error::Other
})?;
let primary_pub = PublicBuilder::new()
.with_public_algorithm(PublicAlgorithm::Rsa)
.with_name_hashing_algorithm(HashingAlgorithm::Sha256)
.with_object_attributes(object_attributes)
.with_rsa_parameters(rsa_params)
.with_rsa_unique_identifier(PublicKeyRsa::default())
.build()
.map_err(|e| {
log::error!("Failed to build primary key public: {}", e);
soft_fido2::Error::Other
})?;
let primary_key_result = context
.create_primary(Hierarchy::Owner, primary_pub, None, None, None, None)
.map_err(|e| {
log::error!("Failed to create primary key: {}", e);
soft_fido2::Error::Other
})?;
Ok(primary_key_result.key_handle)
}
fn create_sealing_public(&self) -> Result<Public> {
let object_attributes = tss_esapi::attributes::ObjectAttributesBuilder::new()
.with_fixed_tpm(true)
.with_fixed_parent(true)
.with_user_with_auth(true)
.build()
.map_err(|e| {
log::error!("Failed to build sealing object attributes: {}", e);
soft_fido2::Error::Other
})?;
let keyed_hash_params = PublicKeyedHashParameters::new(KeyedHashScheme::Null);
PublicBuilder::new()
.with_public_algorithm(PublicAlgorithm::KeyedHash)
.with_name_hashing_algorithm(HashingAlgorithm::Sha256)
.with_object_attributes(object_attributes)
.with_keyed_hash_parameters(keyed_hash_params)
.with_keyed_hash_unique_identifier(tss_esapi::structures::Digest::default())
.build()
.map_err(|e| {
log::error!("Failed to build sealing object public: {}", e);
soft_fido2::Error::Other
})
}
fn seal_data(&self, data: &[u8]) -> Result<Vec<u8>> {
debug!("Sealing {} bytes with TPM (hybrid encryption)", data.len());
let mut aes_key = [0u8; 32];
OsRng.fill_bytes(&mut aes_key);
let mut nonce_bytes = [0u8; 12];
OsRng.fill_bytes(&mut nonce_bytes);
let nonce = Nonce::from_slice(&nonce_bytes);
let cipher = Aes256Gcm::new_from_slice(&aes_key).map_err(|e| {
log::error!("Failed to create AES cipher: {}", e);
soft_fido2::Error::Other
})?;
let encrypted_data = cipher.encrypt(nonce, data).map_err(|e| {
log::error!("Failed to encrypt data with AES-GCM: {}", e);
soft_fido2::Error::Other
})?;
debug!(
"Encrypted {} bytes to {} bytes, now sealing 32-byte AES key with TPM",
data.len(),
encrypted_data.len()
);
let mut context = self.context.lock().map_err(|e| {
log::error!("Failed to lock TPM context: {}", e);
soft_fido2::Error::Other
})?;
let primary_key = self.create_primary_key(&mut context)?;
let sealing_pub = self.create_sealing_public()?;
let sensitive_data = tss_esapi::structures::SensitiveData::try_from(aes_key.to_vec())
.map_err(|e| {
log::error!("Failed to create sensitive data for AES key: {}", e);
soft_fido2::Error::Other
})?;
let create_result = context
.create(
primary_key,
sealing_pub,
None,
Some(sensitive_data),
None,
None,
)
.map_err(|e| {
log::error!("Failed to create sealed object: {}", e);
soft_fido2::Error::Other
})?;
context.flush_context(primary_key.into()).map_err(|e| {
log::error!("Failed to flush primary key: {}", e);
soft_fido2::Error::Other
})?;
let private_tpm: TPM2B_PRIVATE = create_result.out_private.into();
let private_bytes = private_tpm.buffer[..private_tpm.size as usize].to_vec();
#[allow(clippy::unnecessary_fallible_conversions)]
let public_tpm: TPM2B_PUBLIC = create_result.out_public.try_into().map_err(|e| {
log::error!("Failed to convert public to TPM2B: {:?}", e);
soft_fido2::Error::Other
})?;
let public_bytes = unsafe {
let ptr = &public_tpm as *const TPM2B_PUBLIC as *const u8;
std::slice::from_raw_parts(ptr, std::mem::size_of::<TPM2B_PUBLIC>()).to_vec()
};
let sealed_blob = SealedBlob {
encrypted_data,
nonce: nonce_bytes.to_vec(),
tpm_private: private_bytes,
tpm_public: public_bytes,
};
let serialized = Zeroizing::new(serde_json::to_vec(&sealed_blob).map_err(|e| {
log::error!("Failed to serialize sealed blob: {}", e);
soft_fido2::Error::Other
})?);
Ok(serialized.to_vec())
}
fn unseal_data(&self, sealed_data: &[u8]) -> Result<Vec<u8>> {
debug!("Unsealing data with TPM (hybrid decryption)");
let mut context = self.context.lock().map_err(|e| {
log::error!("Failed to lock TPM context: {}", e);
soft_fido2::Error::Other
})?;
let sealed_blob: SealedBlob = serde_json::from_slice(sealed_data).map_err(|e| {
log::error!("Failed to deserialize sealed blob: {}", e);
soft_fido2::Error::Other
})?;
let primary_key = self.create_primary_key(&mut context)?;
let mut private_tpm = TPM2B_PRIVATE {
size: sealed_blob.tpm_private.len() as u16,
buffer: [0u8; 1550],
};
private_tpm.buffer[..sealed_blob.tpm_private.len()]
.copy_from_slice(&sealed_blob.tpm_private);
let public_tpm: TPM2B_PUBLIC = unsafe {
let mut public_struct: TPM2B_PUBLIC = std::mem::zeroed();
let ptr = &mut public_struct as *mut TPM2B_PUBLIC as *mut u8;
std::ptr::copy_nonoverlapping(
sealed_blob.tpm_public.as_ptr(),
ptr,
std::cmp::min(
sealed_blob.tpm_public.len(),
std::mem::size_of::<TPM2B_PUBLIC>(),
),
);
public_struct
};
let private = tss_esapi::structures::Private::try_from(private_tpm).map_err(|e| {
log::error!("Failed to convert TPM2B_PRIVATE to Private: {}", e);
soft_fido2::Error::Other
})?;
let public = tss_esapi::structures::Public::try_from(public_tpm).map_err(|e| {
log::error!("Failed to convert TPM2B_PUBLIC to Public: {}", e);
soft_fido2::Error::Other
})?;
let sealed_handle = context.load(primary_key, private, public).map_err(|e| {
log::error!("Failed to load sealed object: {}", e);
soft_fido2::Error::Other
})?;
let unsealed_key = context.unseal(sealed_handle.into()).map_err(|e| {
log::error!("Failed to unseal AES key: {}", e);
soft_fido2::Error::Other
})?;
context.flush_context(sealed_handle.into()).map_err(|e| {
log::error!("Failed to flush sealed handle: {}", e);
soft_fido2::Error::Other
})?;
context.flush_context(primary_key.into()).map_err(|e| {
log::error!("Failed to flush primary key: {}", e);
soft_fido2::Error::Other
})?;
drop(context);
let aes_key = unsealed_key.value();
if aes_key.len() != 32 {
log::error!(
"Unsealed key has wrong size: {} (expected 32)",
aes_key.len()
);
return Err(soft_fido2::Error::Other);
}
let nonce = Nonce::from_slice(&sealed_blob.nonce);
let cipher = Aes256Gcm::new_from_slice(aes_key).map_err(|e| {
log::error!("Failed to create AES cipher: {}", e);
soft_fido2::Error::Other
})?;
let decrypted_data = cipher
.decrypt(nonce, sealed_blob.encrypted_data.as_ref())
.map_err(|e| {
log::error!("Failed to decrypt data with AES-GCM: {}", e);
soft_fido2::Error::Other
})?;
debug!("Successfully decrypted {} bytes", decrypted_data.len());
Ok(decrypted_data)
}
#[allow(dead_code)]
fn read_credential_from_path_no_cache(&self, path: &Path) -> Result<soft_fido2::Credential> {
debug!("Reading credential (no cache) from path: {:?}", path);
let mut file = File::open(path).map_err(|e| {
log::error!("Failed to open credential file {}: {}", path.display(), e);
soft_fido2::Error::DoesNotExist
})?;
let mut sealed_data = Vec::new();
file.read_to_end(&mut sealed_data).map_err(|e| {
log::error!("Failed to read credential file {}: {}", path.display(), e);
soft_fido2::Error::Other
})?;
let unsealed_data = Zeroizing::new(self.unseal_data(&sealed_data)?);
Credential::from_bytes(&unsealed_data).map(|cred| cred.to_soft_fido2())
}
fn read_credential_from_path(&mut self, path: &Path) -> Result<soft_fido2::Credential> {
if let Some(cached) = self.cache.get(path) {
if Instant::now() < cached.expires_at {
debug!("Cache HIT for path: {:?}", path);
return Ok(cached.credential.clone());
} else {
debug!("Cache entry expired for path: {:?}", path);
}
}
debug!(
"Cache MISS - reading and unsealing credential from path: {:?}",
path
);
self.cache.evict_expired();
self.cache.evict_oldest_if_full();
let mut file = File::open(path).map_err(|e| {
log::error!("Failed to open credential file {}: {}", path.display(), e);
soft_fido2::Error::DoesNotExist
})?;
let mut sealed_data = Vec::new();
file.read_to_end(&mut sealed_data).map_err(|e| {
log::error!("Failed to read credential file {}: {}", path.display(), e);
soft_fido2::Error::Other
})?;
let unsealed_data = Zeroizing::new(self.unseal_data(&sealed_data)?);
let credential = Credential::from_bytes(&unsealed_data).map(|cred| cred.to_soft_fido2())?;
self.cache.insert(path.to_path_buf(), credential.clone());
Ok(credential)
}
fn read_credential_by_id(&mut self, id: &[u8]) -> Result<soft_fido2::Credential> {
let path_info = self.indexes.id.get(id).ok_or_else(|| {
debug!("Credential not found in index");
soft_fido2::Error::DoesNotExist
})?;
let path = path_info.to_path(&self.storage_dir);
self.read_credential_from_path(&path)
}
fn find_next(&mut self) -> Result<soft_fido2::Credential> {
debug!(
"Finding next credential (index: {}/{})",
self.iteration_index,
self.iteration_entries.len()
);
if self.iteration_index >= self.iteration_entries.len() {
debug!("No more credentials matching filter");
return Err(soft_fido2::Error::DoesNotExist);
}
let path = self.iteration_entries[self.iteration_index].clone();
self.iteration_index += 1;
self.read_credential_from_path(&path)
}
fn write_credential(&mut self, cred: &soft_fido2::Credential) -> Result<()> {
self.cache.evict_expired();
let path = get_credential_path(&self.storage_dir, &cred.rp.id, &cred.id, "tpm");
debug!("Writing credential to: {:?}", path);
if let Some(parent) = path.parent() {
create_secure_dir_all(parent).map_err(|e| {
debug!("Failed to create directory: {}", e);
soft_fido2::Error::Other
})?;
}
let our_cred = Credential::from_soft_fido2(cred);
let bytes = Zeroizing::new(our_cred.to_bytes()?);
let sealed_data = self.seal_data(&bytes)?;
let mut file = create_secure_file(&path).map_err(|e| {
log::error!("Failed to create credential file {}: {}", path.display(), e);
soft_fido2::Error::Other
})?;
file.write_all(&sealed_data).map_err(|e| {
log::error!("Failed to write credential file {}: {}", path.display(), e);
soft_fido2::Error::Other
})?;
debug!("Successfully wrote and sealed credential");
self.cache.remove(&path);
let path_info =
CredentialPathInfo::new(cred.rp.id.clone(), cred.id.to_vec(), "tpm".to_string());
update_indexes_on_write(&mut self.indexes, path_info);
Ok(())
}
fn delete_credential(&mut self, id: &[u8]) -> Result<()> {
self.cache.evict_expired();
debug!("Deleting credential with ID: {}", bytes_to_hex(id));
let path_info = self
.indexes
.id
.get(id)
.ok_or_else(|| {
debug!("Credential not found in index");
soft_fido2::Error::DoesNotExist
})?
.clone();
let path = path_info.to_path(&self.storage_dir);
std::fs::remove_file(&path).map_err(|e| {
debug!("Failed to delete file: {}", e);
soft_fido2::Error::Other
})?;
self.cache.remove(&path);
update_indexes_on_delete(&mut self.indexes, id);
debug!("Successfully deleted credential");
Ok(())
}
}
impl CredentialStorage for TpmStorageAdapter {
fn read_first(&mut self, filter: CredentialFilter) -> Result<soft_fido2::Credential> {
self.cache.evict_expired();
debug!("read_first called with filter: {:?}", filter);
self.iteration_entries = match &filter {
CredentialFilter::None => {
self.indexes
.id
.values()
.map(|path_info| path_info.to_path(&self.storage_dir))
.collect()
}
CredentialFilter::ById(id) => {
if let Some(path_info) = self.indexes.id.get(id.as_slice()) {
vec![path_info.to_path(&self.storage_dir)]
} else {
Vec::new()
}
}
CredentialFilter::ByRp(rp_id) => {
self.indexes
.rp
.get(rp_id.as_str())
.map(|cred_ids| {
cred_ids
.iter()
.filter_map(|cred_id| {
self.indexes
.id
.get(cred_id)
.map(|path_info| path_info.to_path(&self.storage_dir))
})
.collect()
})
.unwrap_or_default()
}
CredentialFilter::ByHash(hash) => {
self.indexes
.rp_hash
.get(hash)
.map(|cred_ids| {
cred_ids
.iter()
.filter_map(|cred_id| {
self.indexes
.id
.get(cred_id)
.map(|path_info| path_info.to_path(&self.storage_dir))
})
.collect()
})
.unwrap_or_default()
}
};
self.iteration_index = 0;
debug!(
"Starting iteration with {} entries for filter: {:?}",
self.iteration_entries.len(),
filter
);
self.find_next()
}
fn read_next(&mut self) -> Result<soft_fido2::Credential> {
self.cache.evict_expired();
debug!("read_next called");
self.find_next()
}
fn read(&mut self, id: &[u8]) -> Result<soft_fido2::Credential> {
self.cache.evict_expired();
debug!("read called with id: {}", bytes_to_hex(id));
self.read_credential_by_id(id)
}
fn write(&mut self, cred_ref: soft_fido2::CredentialRef) -> Result<()> {
self.cache.evict_expired();
debug!("write called for RP: {}", cred_ref.rp_id);
let credential = cred_ref.to_owned();
self.write_credential(&credential)
}
fn delete(&mut self, id: &[u8]) -> Result<()> {
self.cache.evict_expired();
debug!("delete called with id: {}", bytes_to_hex(id));
self.delete_credential(id)
}
fn count_credentials(&self) -> usize {
let count = self.indexes.id.len();
debug!("count_credentials: {}", count);
count
}
fn disable_user_verification(&self) -> bool {
false
}
fn cleanup_expired_cache(&mut self) {
self.cache.evict_expired();
}
}