use pem::Pem;
use sha3::{Digest, Sha3_256};
use crate::error::PqfileError;
use crate::sign;
const CERT_TAG: &str = "PQFILE CERTIFICATE";
const CERT_MAGIC: &[u8; 4] = b"PQFC";
const CERT_VERSION: u8 = 1;
const MAX_LABEL_LEN: usize = 256;
const MAX_TAG_LEN: usize = 64;
const MAX_SUBJECT_KEY_LEN: usize = 16_384;
pub mod cert_use {
pub const ENCRYPT: u8 = 0x01;
pub const SIGN: u8 = 0x02;
}
const REVOCATION_TAG: &str = "PQFILE CERTIFICATE REVOCATION LIST";
const REVOCATION_MAGIC: &[u8; 4] = b"PQRL";
const REVOCATION_VERSION: u8 = 1;
const MAX_REVOCATION_REASON_LEN: usize = MAX_LABEL_LEN;
const MAX_REVOCATION_ENTRIES: usize = 65_536;
const CERT_ID_LEN: usize = 32;
#[non_exhaustive]
#[derive(Debug, Clone)]
pub struct Certificate {
pub label: String,
pub not_before: u64,
pub not_after: u64,
pub allowed_use: u8,
pub subject_pem: String,
}
impl Certificate {
#[must_use]
pub fn is_valid_at(&self, now: u64) -> bool {
now >= self.not_before && now <= self.not_after
}
#[must_use]
pub fn permits(&self, use_mask: u8) -> bool {
self.allowed_use & use_mask == use_mask
}
}
#[must_use = "issued certificate must be saved or distributed"]
pub fn issue_cert(
ca_sk_pem: &str,
ca_passphrase: Option<&str>,
subject_pem: &str,
label: &str,
not_before: u64,
not_after: u64,
allowed_use: u8,
) -> Result<String, PqfileError> {
if label.len() > MAX_LABEL_LEN {
return Err(PqfileError::InvalidPem(format!(
"certificate label exceeds {MAX_LABEL_LEN} bytes"
)));
}
if not_after < not_before {
return Err(PqfileError::InvalidPem(
"certificate not_after precedes not_before".into(),
));
}
let subject = pem::parse(subject_pem).map_err(|e| PqfileError::InvalidPem(e.to_string()))?;
if subject.tag().len() > MAX_TAG_LEN {
return Err(PqfileError::InvalidPem(
"certificate subject PEM tag exceeds maximum length".into(),
));
}
if subject.tag().chars().any(|c| c.is_control()) {
return Err(PqfileError::InvalidPem(
"certificate subject PEM tag contains control characters".into(),
));
}
if subject.contents().len() > MAX_SUBJECT_KEY_LEN {
return Err(PqfileError::InvalidPem(
"certificate subject key exceeds maximum size".into(),
));
}
let body = encode_body(
label,
not_before,
not_after,
allowed_use,
subject.tag(),
subject.contents(),
);
sign_and_frame(body, ca_sk_pem, ca_passphrase, CERT_TAG)
}
#[must_use = "verify result must be used"]
pub fn verify_cert(ca_vk_pem: &str, cert_pem: &str, now: u64) -> Result<Certificate, PqfileError> {
let p = pem::parse(cert_pem).map_err(|e| PqfileError::InvalidPem(e.to_string()))?;
if p.tag() != CERT_TAG {
return Err(PqfileError::InvalidPem(format!(
"expected tag '{CERT_TAG}', got '{}'",
p.tag()
)));
}
let data = p.contents();
let (cert, body_end) = parse_fields(data)?;
verify_signed_body(
ca_vk_pem,
data,
body_end,
"certificate has trailing bytes after signature",
)?;
if !cert.is_valid_at(now) {
return Err(PqfileError::CertNotValid {
not_before: cert.not_before,
not_after: cert.not_after,
now,
});
}
Ok(cert)
}
#[must_use]
pub fn is_certificate(pem_str: &str) -> bool {
pem::parse(pem_str)
.map(|p| p.tag() == CERT_TAG)
.unwrap_or(false)
}
pub fn cert_id(cert_pem: &str) -> Result<[u8; CERT_ID_LEN], PqfileError> {
let p = pem::parse(cert_pem).map_err(|e| PqfileError::InvalidPem(e.to_string()))?;
if p.tag() != CERT_TAG {
return Err(PqfileError::InvalidPem(format!(
"expected tag '{CERT_TAG}', got '{}'",
p.tag()
)));
}
let data = p.contents();
let (_, body_end) = parse_fields(data)?;
Ok(Sha3_256::digest(&data[..body_end]).into())
}
#[derive(Debug, Clone)]
pub struct RevokedEntry {
pub cert_id: [u8; CERT_ID_LEN],
pub revoked_at: u64,
pub reason: String,
}
#[non_exhaustive]
#[derive(Debug, Clone)]
pub struct RevocationList {
pub issued_at: u64,
pub entries: Vec<RevokedEntry>,
}
impl RevocationList {
#[must_use]
pub fn find(&self, id: &[u8; CERT_ID_LEN]) -> Option<&RevokedEntry> {
self.entries.iter().find(|e| &e.cert_id == id)
}
}
#[must_use = "revoked list must be saved or distributed"]
pub fn revoke_cert(
ca_sk_pem: &str,
ca_passphrase: Option<&str>,
existing_list_pem: Option<&str>,
cert_pem_to_revoke: &str,
reason: &str,
now: u64,
) -> Result<String, PqfileError> {
if reason.len() > MAX_REVOCATION_REASON_LEN {
return Err(PqfileError::InvalidPem(format!(
"revocation reason exceeds {MAX_REVOCATION_REASON_LEN} bytes"
)));
}
let id = cert_id(cert_pem_to_revoke)?;
let mut entries = match existing_list_pem {
Some(pem_str) => parse_revocation_list_structural(pem_str)?.1,
None => Vec::new(),
};
if entries.len() >= MAX_REVOCATION_ENTRIES {
return Err(PqfileError::InvalidPem(format!(
"revocation list already holds the maximum of {MAX_REVOCATION_ENTRIES} entries"
)));
}
entries.push(RevokedEntry {
cert_id: id,
revoked_at: now,
reason: reason.to_owned(),
});
let body = encode_revocation_body(now, &entries);
sign_and_frame(body, ca_sk_pem, ca_passphrase, REVOCATION_TAG)
}
#[must_use = "verify result must be used"]
pub fn verify_revocation_list(
ca_vk_pem: &str,
list_pem: &str,
) -> Result<RevocationList, PqfileError> {
let p = pem::parse(list_pem).map_err(|e| PqfileError::InvalidPem(e.to_string()))?;
if p.tag() != REVOCATION_TAG {
return Err(PqfileError::InvalidPem(format!(
"expected tag '{REVOCATION_TAG}', got '{}'",
p.tag()
)));
}
let data = p.contents();
let (issued_at, entries, body_end) = parse_revocation_fields(data)?;
verify_signed_body(
ca_vk_pem,
data,
body_end,
"revocation list has trailing bytes after signature",
)?;
Ok(RevocationList { issued_at, entries })
}
#[must_use]
pub fn is_revocation_list(pem_str: &str) -> bool {
pem::parse(pem_str)
.map(|p| p.tag() == REVOCATION_TAG)
.unwrap_or(false)
}
#[must_use]
pub fn cert_id_hex(id: &[u8; CERT_ID_LEN]) -> String {
id.iter()
.take(16)
.map(|b| format!("{b:02x}"))
.collect::<Vec<_>>()
.join(":")
}
#[must_use = "revocation check must be inspected; ignoring it means trusting a revoked certificate"]
pub fn check_cert_not_revoked(list: &RevocationList, cert_pem: &str) -> Result<(), PqfileError> {
let id = cert_id(cert_pem)?;
if let Some(entry) = list.find(&id) {
return Err(PqfileError::CertRevoked {
cert_id: cert_id_hex(&id),
reason: entry.reason.clone(),
});
}
Ok(())
}
#[must_use = "revocation check must be inspected; ignoring it means trusting a revoked certificate"]
pub fn check_cert_not_revoked_pem(
ca_vk_pem: &str,
revocation_list_pem: Option<&str>,
cert_pem: &str,
) -> Result<(), PqfileError> {
let Some(list_pem) = revocation_list_pem else {
return Ok(());
};
let list = verify_revocation_list(ca_vk_pem, list_pem)?;
check_cert_not_revoked(&list, cert_pem)
}
fn encode_body(
label: &str,
not_before: u64,
not_after: u64,
allowed_use: u8,
subject_tag: &str,
subject_key: &[u8],
) -> Vec<u8> {
let label_bytes = label.as_bytes();
let tag_bytes = subject_tag.as_bytes();
let mut out = Vec::with_capacity(
4 + 1 + 8 + 8 + 1 + 2 + label_bytes.len() + 1 + tag_bytes.len() + 4 + subject_key.len(),
);
out.extend_from_slice(CERT_MAGIC);
out.push(CERT_VERSION);
out.extend_from_slice(¬_before.to_le_bytes());
out.extend_from_slice(¬_after.to_le_bytes());
out.push(allowed_use);
out.extend_from_slice(&(label_bytes.len() as u16).to_le_bytes());
out.extend_from_slice(label_bytes);
out.push(tag_bytes.len() as u8);
out.extend_from_slice(tag_bytes);
out.extend_from_slice(&(subject_key.len() as u32).to_le_bytes());
out.extend_from_slice(subject_key);
out
}
fn parse_fields(data: &[u8]) -> Result<(Certificate, usize), PqfileError> {
let mut pos = 0usize;
let magic = take(data, &mut pos, 4)?;
if magic != CERT_MAGIC {
return Err(PqfileError::InvalidPem(
"not a pqfile certificate (bad magic)".into(),
));
}
let version = take(data, &mut pos, 1)?[0];
if version != CERT_VERSION {
return Err(PqfileError::InvalidPem(format!(
"unsupported certificate version: {version}"
)));
}
let not_before = u64::from_le_bytes(take(data, &mut pos, 8)?.try_into().unwrap());
let not_after = u64::from_le_bytes(take(data, &mut pos, 8)?.try_into().unwrap());
let allowed_use = take(data, &mut pos, 1)?[0];
let label_len = u16::from_le_bytes(take(data, &mut pos, 2)?.try_into().unwrap()) as usize;
if label_len > MAX_LABEL_LEN {
return Err(PqfileError::InvalidPem(
"certificate label exceeds maximum length".into(),
));
}
let label = String::from_utf8(take(data, &mut pos, label_len)?.to_vec())
.map_err(|_| PqfileError::InvalidPem("certificate label is not valid UTF-8".into()))?;
let tag_len = take(data, &mut pos, 1)?[0] as usize;
if tag_len > MAX_TAG_LEN {
return Err(PqfileError::InvalidPem(
"certificate subject PEM tag exceeds maximum length".into(),
));
}
let subject_tag = String::from_utf8(take(data, &mut pos, tag_len)?.to_vec()).map_err(|_| {
PqfileError::InvalidPem("certificate subject tag is not valid UTF-8".into())
})?;
let key_len = u32::from_le_bytes(take(data, &mut pos, 4)?.try_into().unwrap()) as usize;
if key_len > MAX_SUBJECT_KEY_LEN {
return Err(PqfileError::InvalidPem(
"certificate subject key exceeds maximum size".into(),
));
}
let subject_key = take(data, &mut pos, key_len)?;
let subject_pem = pem::encode(&Pem::new(subject_tag, subject_key.to_vec()));
Ok((
Certificate {
label,
not_before,
not_after,
allowed_use,
subject_pem,
},
pos,
))
}
fn take<'a>(data: &'a [u8], pos: &mut usize, len: usize) -> Result<&'a [u8], PqfileError> {
if *pos + len > data.len() {
return Err(PqfileError::InvalidPem("certificate truncated".into()));
}
let slice = &data[*pos..*pos + len];
*pos += len;
Ok(slice)
}
fn sign_and_frame(
body: Vec<u8>,
sk_pem: &str,
passphrase: Option<&str>,
tag: &str,
) -> Result<String, PqfileError> {
let sig = sign::sign_bytes(sk_pem, &body, passphrase)?;
let mut out = body;
out.extend_from_slice(&(sig.len() as u32).to_le_bytes());
out.extend_from_slice(&sig);
Ok(pem::encode(&Pem::new(tag, out)))
}
fn verify_signed_body<'a>(
vk_pem: &str,
data: &'a [u8],
body_end: usize,
trailing_bytes_msg: &str,
) -> Result<&'a [u8], PqfileError> {
let body = &data[..body_end];
let mut pos = body_end;
let sig_len = u32::from_le_bytes(take(data, &mut pos, 4)?.try_into().unwrap()) as usize;
let sig = take(data, &mut pos, sig_len)?;
if pos != data.len() {
return Err(PqfileError::InvalidPem(trailing_bytes_msg.into()));
}
sign::verify_bytes(vk_pem, body, sig)?;
Ok(body)
}
fn encode_revocation_body(issued_at: u64, entries: &[RevokedEntry]) -> Vec<u8> {
let mut out = Vec::with_capacity(4 + 1 + 8 + 4 + entries.len() * (CERT_ID_LEN + 8 + 2));
out.extend_from_slice(REVOCATION_MAGIC);
out.push(REVOCATION_VERSION);
out.extend_from_slice(&issued_at.to_le_bytes());
out.extend_from_slice(&(entries.len() as u32).to_le_bytes());
for e in entries {
out.extend_from_slice(&e.cert_id);
out.extend_from_slice(&e.revoked_at.to_le_bytes());
let reason_bytes = e.reason.as_bytes();
out.extend_from_slice(&(reason_bytes.len() as u16).to_le_bytes());
out.extend_from_slice(reason_bytes);
}
out
}
fn parse_revocation_fields(data: &[u8]) -> Result<(u64, Vec<RevokedEntry>, usize), PqfileError> {
let mut pos = 0usize;
let magic = take(data, &mut pos, 4)?;
if magic != REVOCATION_MAGIC {
return Err(PqfileError::InvalidPem(
"not a pqfile revocation list (bad magic)".into(),
));
}
let version = take(data, &mut pos, 1)?[0];
if version != REVOCATION_VERSION {
return Err(PqfileError::InvalidPem(format!(
"unsupported revocation list version: {version}"
)));
}
let issued_at = u64::from_le_bytes(take(data, &mut pos, 8)?.try_into().unwrap());
let count = u32::from_le_bytes(take(data, &mut pos, 4)?.try_into().unwrap()) as usize;
if count > MAX_REVOCATION_ENTRIES {
return Err(PqfileError::InvalidPem(
"revocation list entry count exceeds maximum".into(),
));
}
let mut entries = Vec::with_capacity(count.min(1024));
for _ in 0..count {
let cert_id: [u8; CERT_ID_LEN] = take(data, &mut pos, CERT_ID_LEN)?.try_into().unwrap();
let revoked_at = u64::from_le_bytes(take(data, &mut pos, 8)?.try_into().unwrap());
let reason_len = u16::from_le_bytes(take(data, &mut pos, 2)?.try_into().unwrap()) as usize;
if reason_len > MAX_REVOCATION_REASON_LEN {
return Err(PqfileError::InvalidPem(
"revocation reason exceeds maximum length".into(),
));
}
let reason = String::from_utf8(take(data, &mut pos, reason_len)?.to_vec())
.map_err(|_| PqfileError::InvalidPem("revocation reason is not valid UTF-8".into()))?;
entries.push(RevokedEntry {
cert_id,
revoked_at,
reason,
});
}
Ok((issued_at, entries, pos))
}
fn parse_revocation_list_structural(
list_pem: &str,
) -> Result<(u64, Vec<RevokedEntry>), PqfileError> {
let p = pem::parse(list_pem).map_err(|e| PqfileError::InvalidPem(e.to_string()))?;
if p.tag() != REVOCATION_TAG {
return Err(PqfileError::InvalidPem(format!(
"expected tag '{REVOCATION_TAG}', got '{}'",
p.tag()
)));
}
let (issued_at, entries, _) = parse_revocation_fields(p.contents())?;
Ok((issued_at, entries))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::keygen::keygen_bytes;
use crate::sign::sign_keygen_bytes;
fn ca() -> (String, String) {
let r = sign_keygen_bytes(None).unwrap();
(r.vk_pem, r.sk_pem)
}
#[test]
fn issue_and_verify_roundtrip() {
let (ca_vk, ca_sk) = ca();
let (subject_pub, _) = keygen_bytes(768, None).unwrap();
let cert_pem = issue_cert(
&ca_sk,
None,
&subject_pub,
"alice's laptop",
1_000,
2_000,
cert_use::ENCRYPT,
)
.unwrap();
let cert = verify_cert(&ca_vk, &cert_pem, 1_500).unwrap();
assert_eq!(cert.label, "alice's laptop");
assert_eq!(cert.not_before, 1_000);
assert_eq!(cert.not_after, 2_000);
assert!(cert.permits(cert_use::ENCRYPT));
assert!(!cert.permits(cert_use::SIGN));
assert_eq!(
pem::parse(&cert.subject_pem).unwrap().tag(),
"ML-KEM-768 PUBLIC KEY"
);
}
#[test]
fn verify_rejects_before_validity_window() {
let (ca_vk, ca_sk) = ca();
let (subject_pub, _) = keygen_bytes(768, None).unwrap();
let cert_pem = issue_cert(
&ca_sk,
None,
&subject_pub,
"x",
1_000,
2_000,
cert_use::ENCRYPT,
)
.unwrap();
let err = verify_cert(&ca_vk, &cert_pem, 999).unwrap_err();
assert!(matches!(err, PqfileError::CertNotValid { .. }));
}
#[test]
fn verify_rejects_after_validity_window() {
let (ca_vk, ca_sk) = ca();
let (subject_pub, _) = keygen_bytes(768, None).unwrap();
let cert_pem = issue_cert(
&ca_sk,
None,
&subject_pub,
"x",
1_000,
2_000,
cert_use::ENCRYPT,
)
.unwrap();
let err = verify_cert(&ca_vk, &cert_pem, 2_001).unwrap_err();
assert!(matches!(err, PqfileError::CertNotValid { .. }));
}
#[test]
fn verify_accepts_window_boundaries() {
let (ca_vk, ca_sk) = ca();
let (subject_pub, _) = keygen_bytes(768, None).unwrap();
let cert_pem = issue_cert(
&ca_sk,
None,
&subject_pub,
"x",
1_000,
2_000,
cert_use::ENCRYPT,
)
.unwrap();
verify_cert(&ca_vk, &cert_pem, 1_000).unwrap();
verify_cert(&ca_vk, &cert_pem, 2_000).unwrap();
}
#[test]
fn verify_rejects_wrong_ca_key() {
let (_, ca_sk) = ca();
let (other_ca_vk, _) = ca();
let (subject_pub, _) = keygen_bytes(768, None).unwrap();
let cert_pem = issue_cert(
&ca_sk,
None,
&subject_pub,
"x",
1_000,
2_000,
cert_use::ENCRYPT,
)
.unwrap();
let err = verify_cert(&other_ca_vk, &cert_pem, 1_500).unwrap_err();
assert!(matches!(err, PqfileError::SignatureVerificationFailed));
}
#[test]
fn verify_rejects_tampered_body() {
let (ca_vk, ca_sk) = ca();
let (subject_pub, _) = keygen_bytes(768, None).unwrap();
let cert_pem = issue_cert(
&ca_sk,
None,
&subject_pub,
"x",
1_000,
2_000,
cert_use::ENCRYPT,
)
.unwrap();
let p = pem::parse(&cert_pem).unwrap();
let tag = p.tag().to_owned();
let mut contents = p.into_contents();
contents[10] ^= 0xff;
let tampered = pem::encode(&Pem::new(tag, contents));
let err = verify_cert(&ca_vk, &tampered, 1_500).unwrap_err();
assert!(matches!(err, PqfileError::SignatureVerificationFailed));
}
#[test]
fn verify_rejects_wrong_pem_tag() {
let (ca_vk, _) = ca();
let wrong = pem::encode(&Pem::new("WRONG TAG", vec![0u8; 16]));
let err = verify_cert(&ca_vk, &wrong, 0).unwrap_err();
assert!(matches!(err, PqfileError::InvalidPem(_)));
}
#[test]
fn issue_rejects_not_after_before_not_before() {
let (_, ca_sk) = ca();
let (subject_pub, _) = keygen_bytes(768, None).unwrap();
let err = issue_cert(
&ca_sk,
None,
&subject_pub,
"x",
2_000,
1_000,
cert_use::ENCRYPT,
)
.unwrap_err();
assert!(matches!(err, PqfileError::InvalidPem(_)));
}
#[test]
fn issue_rejects_oversized_label() {
let (_, ca_sk) = ca();
let (subject_pub, _) = keygen_bytes(768, None).unwrap();
let label = "x".repeat(MAX_LABEL_LEN + 1);
let err = issue_cert(
&ca_sk,
None,
&subject_pub,
&label,
1_000,
2_000,
cert_use::ENCRYPT,
)
.unwrap_err();
assert!(matches!(err, PqfileError::InvalidPem(_)));
}
#[test]
fn issue_rejects_subject_tag_with_control_characters() {
let (_, ca_sk) = ca();
let subject_pub = pem::encode(&Pem::new("ML-KEM-768 PUBLIC\nKEY", vec![0u8; 1184]));
let err = issue_cert(
&ca_sk,
None,
&subject_pub,
"x",
1_000,
2_000,
cert_use::ENCRYPT,
)
.unwrap_err();
assert!(matches!(err, PqfileError::InvalidPem(_)));
}
#[test]
fn permits_checks_combined_bits() {
let (ca_vk, ca_sk) = ca();
let (subject_pub, _) = keygen_bytes(768, None).unwrap();
let cert_pem = issue_cert(
&ca_sk,
None,
&subject_pub,
"x",
1_000,
2_000,
cert_use::ENCRYPT | cert_use::SIGN,
)
.unwrap();
let cert = verify_cert(&ca_vk, &cert_pem, 1_500).unwrap();
assert!(cert.permits(cert_use::ENCRYPT));
assert!(cert.permits(cert_use::SIGN));
assert!(cert.permits(cert_use::ENCRYPT | cert_use::SIGN));
}
#[test]
fn certifies_a_verifying_key_for_sign_use() {
let (ca_vk, ca_sk) = ca();
let subject_signer = sign_keygen_bytes(None).unwrap();
let cert_pem = issue_cert(
&ca_sk,
None,
&subject_signer.vk_pem,
"release signing key",
1_000,
2_000,
cert_use::SIGN,
)
.unwrap();
let cert = verify_cert(&ca_vk, &cert_pem, 1_500).unwrap();
assert!(cert.permits(cert_use::SIGN));
assert_eq!(
pem::parse(&cert.subject_pem).unwrap().tag(),
"ML-DSA-65 VERIFYING KEY"
);
}
#[test]
fn is_certificate_detects_tag() {
let (_, ca_sk) = ca();
let (subject_pub, _) = keygen_bytes(768, None).unwrap();
let cert_pem = issue_cert(
&ca_sk,
None,
&subject_pub,
"x",
1_000,
2_000,
cert_use::ENCRYPT,
)
.unwrap();
assert!(is_certificate(&cert_pem));
assert!(!is_certificate(&subject_pub));
assert!(!is_certificate("not pem at all"));
}
#[test]
fn verify_rejects_truncated_certificate() {
let (ca_vk, ca_sk) = ca();
let (subject_pub, _) = keygen_bytes(768, None).unwrap();
let cert_pem = issue_cert(
&ca_sk,
None,
&subject_pub,
"x",
1_000,
2_000,
cert_use::ENCRYPT,
)
.unwrap();
let p = pem::parse(&cert_pem).unwrap();
let tag = p.tag().to_owned();
let short: Vec<u8> = p.contents()[..2].to_vec();
let truncated = pem::encode(&Pem::new(tag, short));
let err = verify_cert(&ca_vk, &truncated, 1_500).unwrap_err();
assert!(matches!(err, PqfileError::InvalidPem(_)));
}
fn issued(ca_sk: &str, label: &str) -> String {
let (subject_pub, _) = keygen_bytes(768, None).unwrap();
issue_cert(
ca_sk,
None,
&subject_pub,
label,
1_000,
2_000,
cert_use::ENCRYPT,
)
.unwrap()
}
#[test]
fn cert_id_is_stable_for_identical_bodies() {
let (_, ca_sk) = ca();
let (subject_pub, _) = keygen_bytes(768, None).unwrap();
let a = issue_cert(
&ca_sk,
None,
&subject_pub,
"x",
1_000,
2_000,
cert_use::ENCRYPT,
)
.unwrap();
let b = issue_cert(
&ca_sk,
None,
&subject_pub,
"x",
1_000,
2_000,
cert_use::ENCRYPT,
)
.unwrap();
assert_eq!(cert_id(&a).unwrap(), cert_id(&b).unwrap());
}
#[test]
fn cert_id_differs_for_different_labels() {
let (_, ca_sk) = ca();
let a = issued(&ca_sk, "alice");
let b = issued(&ca_sk, "bob");
assert_ne!(cert_id(&a).unwrap(), cert_id(&b).unwrap());
}
#[test]
fn revoke_then_check_fails() {
let (ca_vk, ca_sk) = ca();
let cert_pem = issued(&ca_sk, "x");
let list_pem = revoke_cert(&ca_sk, None, None, &cert_pem, "compromised", 5_000).unwrap();
let list = verify_revocation_list(&ca_vk, &list_pem).unwrap();
assert_eq!(list.issued_at, 5_000);
let err = check_cert_not_revoked(&list, &cert_pem).unwrap_err();
assert!(matches!(err, PqfileError::CertRevoked { .. }));
}
#[test]
fn check_passes_for_unrevoked_cert() {
let (ca_vk, ca_sk) = ca();
let revoked = issued(&ca_sk, "revoked");
let untouched = issued(&ca_sk, "untouched");
let list_pem = revoke_cert(&ca_sk, None, None, &revoked, "x", 5_000).unwrap();
let list = verify_revocation_list(&ca_vk, &list_pem).unwrap();
check_cert_not_revoked(&list, &untouched).unwrap();
}
#[test]
fn revoke_cert_appends_to_existing_list() {
let (ca_vk, ca_sk) = ca();
let a = issued(&ca_sk, "a");
let b = issued(&ca_sk, "b");
let list1 = revoke_cert(&ca_sk, None, None, &a, "first", 5_000).unwrap();
let list2 = revoke_cert(&ca_sk, None, Some(&list1), &b, "second", 6_000).unwrap();
let list = verify_revocation_list(&ca_vk, &list2).unwrap();
assert_eq!(list.entries.len(), 2);
assert_eq!(list.issued_at, 6_000);
check_cert_not_revoked(&list, &a).unwrap_err();
check_cert_not_revoked(&list, &b).unwrap_err();
}
#[test]
fn revocation_reason_carries_through() {
let (ca_vk, ca_sk) = ca();
let cert_pem = issued(&ca_sk, "x");
let list_pem =
revoke_cert(&ca_sk, None, None, &cert_pem, "private key leaked", 5_000).unwrap();
let list = verify_revocation_list(&ca_vk, &list_pem).unwrap();
let err = check_cert_not_revoked(&list, &cert_pem).unwrap_err();
assert!(err.to_string().contains("private key leaked"));
}
#[test]
fn verify_revocation_list_rejects_wrong_ca_key() {
let (_, ca_sk) = ca();
let (other_ca_vk, _) = ca();
let cert_pem = issued(&ca_sk, "x");
let list_pem = revoke_cert(&ca_sk, None, None, &cert_pem, "x", 5_000).unwrap();
let err = verify_revocation_list(&other_ca_vk, &list_pem).unwrap_err();
assert!(matches!(err, PqfileError::SignatureVerificationFailed));
}
#[test]
fn verify_revocation_list_rejects_tampered_body() {
let (ca_vk, ca_sk) = ca();
let cert_pem = issued(&ca_sk, "x");
let list_pem = revoke_cert(&ca_sk, None, None, &cert_pem, "tamper me", 5_000).unwrap();
let p = pem::parse(&list_pem).unwrap();
let tag = p.tag().to_owned();
let mut contents = p.into_contents();
contents[20] ^= 0xff;
let tampered = pem::encode(&Pem::new(tag, contents));
let err = verify_revocation_list(&ca_vk, &tampered).unwrap_err();
assert!(matches!(err, PqfileError::SignatureVerificationFailed));
}
#[test]
fn revoke_cert_rejects_oversized_reason() {
let (_, ca_sk) = ca();
let cert_pem = issued(&ca_sk, "x");
let reason = "x".repeat(MAX_REVOCATION_REASON_LEN + 1);
let err = revoke_cert(&ca_sk, None, None, &cert_pem, &reason, 5_000).unwrap_err();
assert!(matches!(err, PqfileError::InvalidPem(_)));
}
#[test]
fn verify_revocation_list_rejects_oversized_entry_count() {
let (ca_vk, ca_sk) = ca();
let mut body = Vec::new();
body.extend_from_slice(REVOCATION_MAGIC);
body.push(REVOCATION_VERSION);
body.extend_from_slice(&5_000u64.to_le_bytes());
body.extend_from_slice(&((MAX_REVOCATION_ENTRIES as u32) + 1).to_le_bytes());
let sig = sign::sign_bytes(&ca_sk, &body, None).unwrap();
let mut out = body;
out.extend_from_slice(&(sig.len() as u32).to_le_bytes());
out.extend_from_slice(&sig);
let crafted = pem::encode(&Pem::new(REVOCATION_TAG, out));
let err = verify_revocation_list(&ca_vk, &crafted).unwrap_err();
assert!(matches!(err, PqfileError::InvalidPem(_)));
}
#[test]
fn is_revocation_list_detects_tag() {
let (_, ca_sk) = ca();
let cert_pem = issued(&ca_sk, "x");
let list_pem = revoke_cert(&ca_sk, None, None, &cert_pem, "x", 5_000).unwrap();
assert!(is_revocation_list(&list_pem));
assert!(!is_revocation_list(&cert_pem));
assert!(!is_revocation_list("not pem at all"));
}
#[test]
fn verify_revocation_list_rejects_wrong_pem_tag() {
let (ca_vk, _) = ca();
let wrong = pem::encode(&Pem::new("WRONG TAG", vec![0u8; 16]));
let err = verify_revocation_list(&ca_vk, &wrong).unwrap_err();
assert!(matches!(err, PqfileError::InvalidPem(_)));
}
}