use std::path::{Path, PathBuf};
use tenzro_crypto::pq::{MlDsaSigningKey, ML_DSA_65_VK_LEN};
use crate::{DeviceKeyError, Result};
pub const DEFAULT_PQ_LABEL: &str = "tenzro-pq-companion";
pub fn credential_id_for_label(label: &str) -> [u8; 32] {
tenzro_crypto::sha256(label.as_bytes()).to_bytes()
}
pub struct PqCompanion {
label: String,
ciphertext_path: PathBuf,
signing_key: MlDsaSigningKey,
}
impl PqCompanion {
pub fn create(label: impl Into<String>, ciphertext_path: impl Into<PathBuf>) -> Result<Self> {
let label = label.into();
let ciphertext_path = ciphertext_path.into();
let signing_key = MlDsaSigningKey::generate();
let key = crate::create(&label)?;
let ct = key.wrap_secret(signing_key.seed_bytes())?;
write_ciphertext(&ciphertext_path, &ct)?;
Ok(Self {
label,
ciphertext_path,
signing_key,
})
}
pub fn open(label: impl Into<String>, ciphertext_path: impl Into<PathBuf>) -> Result<Self> {
let label = label.into();
let ciphertext_path = ciphertext_path.into();
let ct = std::fs::read(&ciphertext_path).map_err(|e| {
DeviceKeyError::NotFound(format!(
"no ML-DSA companion ciphertext at {}: {e}",
ciphertext_path.display()
))
})?;
let key = crate::open(&label)?;
let seed = key.unwrap_secret(&ct)?;
let signing_key = MlDsaSigningKey::from_seed(&seed)
.map_err(|e| DeviceKeyError::Enclave(format!("rebuild ML-DSA key: {e}")))?;
Ok(Self {
label,
ciphertext_path,
signing_key,
})
}
pub fn create_under_data_dir(data_dir: impl AsRef<Path>) -> Result<Self> {
Self::create(DEFAULT_PQ_LABEL, companion_path(data_dir))
}
pub fn open_under_data_dir(data_dir: impl AsRef<Path>) -> Result<Self> {
Self::open(DEFAULT_PQ_LABEL, companion_path(data_dir))
}
pub fn verifying_key_bytes(&self) -> &[u8] {
debug_assert_eq!(self.signing_key.verifying_key_bytes().len(), ML_DSA_65_VK_LEN);
self.signing_key.verifying_key_bytes()
}
pub fn sign(&self, msg: &[u8]) -> Vec<u8> {
self.signing_key.sign(msg)
}
pub fn label(&self) -> &str {
&self.label
}
pub fn ciphertext_path(&self) -> &Path {
&self.ciphertext_path
}
}
fn companion_path(data_dir: impl AsRef<Path>) -> PathBuf {
data_dir.as_ref().join("pq-companion.seed.enc")
}
fn write_ciphertext(path: &Path, bytes: &[u8]) -> Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.map_err(|e| DeviceKeyError::Enclave(format!("create dir: {e}")))?;
}
std::fs::write(path, bytes)
.map_err(|e| DeviceKeyError::Enclave(format!("write companion ciphertext: {e}")))
}
#[cfg(test)]
mod tests {
use super::*;
use tenzro_crypto::pq::{ml_dsa_verify, ML_DSA_65_SIG_LEN};
fn companion_from_seed(seed: &[u8]) -> PqCompanion {
PqCompanion {
label: "test".into(),
ciphertext_path: PathBuf::from("/dev/null"),
signing_key: MlDsaSigningKey::from_seed(seed).unwrap(),
}
}
#[test]
fn verifying_key_is_ml_dsa_65_length() {
let c = companion_from_seed(&[7u8; 32]);
assert_eq!(c.verifying_key_bytes().len(), ML_DSA_65_VK_LEN);
}
#[test]
fn sign_produces_verifiable_ml_dsa_signature() {
let c = companion_from_seed(&[9u8; 32]);
let msg = b"tenzro_signWithPasskey pq leg";
let sig = c.sign(msg);
assert_eq!(sig.len(), ML_DSA_65_SIG_LEN);
ml_dsa_verify(c.verifying_key_bytes(), msg, &sig).unwrap();
}
#[test]
fn seed_is_deterministic_across_companions() {
let a = companion_from_seed(&[3u8; 32]);
let b = companion_from_seed(&[3u8; 32]);
assert_eq!(a.verifying_key_bytes(), b.verifying_key_bytes());
}
}