use std::fmt;
use chrono::{DateTime, Duration, Utc};
use serde::{Deserialize, Serialize};
use crate::error::{Error, Result};
pub const DEFAULT_VALIDITY_DAYS: i64 = 365;
const CREDENTIAL_ALGO_V1: u8 = 0x01;
pub const MAX_CREDENTIAL_BYTES: u64 = 4 * 1024;
pub const DOMAIN_TOKEN: u8 = 0x01;
pub const DOMAIN_AUDIT: u8 = 0x02;
pub const DOMAIN_REVOCATION: u8 = 0x03;
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct SpiffeId {
uri: String,
trust_domain_end: usize,
}
impl SpiffeId {
pub fn parse(s: &str) -> Result<Self> {
Self::validate_and_build(s)
}
pub fn new(trust_domain: &str, workload_path: &str) -> Result<Self> {
let uri = format!("spiffe://{trust_domain}/{workload_path}");
Self::validate_and_build(&uri)
}
pub fn as_str(&self) -> &str {
&self.uri
}
pub fn trust_domain(&self) -> &str {
&self.uri[9..self.trust_domain_end]
}
pub fn workload_path(&self) -> &str {
&self.uri[self.trust_domain_end..]
}
fn validate_and_build(s: &str) -> Result<Self> {
let rest = s
.strip_prefix("spiffe://")
.ok_or_else(|| Error::InvalidSpiffeId(format!("must start with 'spiffe://': {s}")))?;
if rest.is_empty() {
return Err(Error::InvalidSpiffeId("trust domain is empty".to_string()));
}
if s.contains('?') || s.contains('#') {
return Err(Error::InvalidSpiffeId(
"SPIFFE IDs must not contain query strings or fragments".to_string(),
));
}
let slash_pos = rest.find('/').ok_or_else(|| {
Error::InvalidSpiffeId("missing workload path (no '/' after trust domain)".to_string())
})?;
let trust_domain = &rest[..slash_pos];
let path = &rest[slash_pos..];
if trust_domain.is_empty() {
return Err(Error::InvalidSpiffeId("trust domain is empty".to_string()));
}
for ch in trust_domain.chars() {
if !ch.is_ascii_alphanumeric() && ch != '-' && ch != '.' {
return Err(Error::InvalidSpiffeId(format!(
"trust domain contains invalid character '{ch}'"
)));
}
}
if path.len() <= 1 {
return Err(Error::InvalidSpiffeId("workload path is empty".to_string()));
}
let trust_domain_end = 9 + slash_pos;
Ok(SpiffeId {
uri: s.to_string(),
trust_domain_end,
})
}
}
impl fmt::Display for SpiffeId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.uri)
}
}
impl std::str::FromStr for SpiffeId {
type Err = Error;
fn from_str(s: &str) -> Result<Self> {
SpiffeId::parse(s)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PqcCredential {
pub spiffe_id: SpiffeId,
pub algo: u8,
pub verifying_key_bytes: Vec<u8>,
pub created_at: DateTime<Utc>,
pub expires_at: DateTime<Utc>,
}
impl PqcCredential {
pub fn is_expired(&self) -> bool {
Utc::now() > self.expires_at
}
pub fn is_valid_at(&self, t: DateTime<Utc>) -> bool {
t >= self.created_at && t <= self.expires_at
}
pub fn to_bytes(&self) -> Result<Vec<u8>> {
bincode::serialize(self)
.map_err(|e| Error::Serialization(format!("credential serialize: {e}")))
}
pub fn from_bytes(bytes: &[u8]) -> Result<Self> {
if bytes.len() as u64 > MAX_CREDENTIAL_BYTES {
return Err(Error::Serialization(format!(
"input exceeds maximum size ({} > {})",
bytes.len(),
MAX_CREDENTIAL_BYTES
)));
}
use bincode::Options as _;
bincode::DefaultOptions::new()
.with_fixint_encoding()
.allow_trailing_bytes()
.with_limit(MAX_CREDENTIAL_BYTES)
.deserialize(bytes)
.map_err(|e| Error::Serialization(format!("credential deserialize: {e}")))
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RevocationStatement {
pub target_credential_bytes: Vec<u8>,
pub revoked_at: DateTime<Utc>,
pub signature: Vec<u8>,
}
impl RevocationStatement {
pub fn verify(
&self,
verifying_key_bytes: &[u8],
claimed_credential_bytes: &[u8],
) -> Result<bool> {
if claimed_credential_bytes != self.target_credential_bytes.as_slice() {
return Ok(false);
}
let ts_secs = self.revoked_at.timestamp().to_le_bytes();
let mut payload = self.target_credential_bytes.clone();
payload.extend_from_slice(&ts_secs);
AgentIdentity::verify_with_domain(
verifying_key_bytes,
DOMAIN_REVOCATION,
&payload,
&self.signature,
)
}
}
pub struct AgentIdentity {
spiffe_id: SpiffeId,
signing_key: lupine::sign::HybridSigningKey65,
credential: PqcCredential,
}
impl AgentIdentity {
pub fn new(trust_domain: &str, workload_id: &str) -> Result<Self> {
let spiffe_id = SpiffeId::new(trust_domain, workload_id)?;
Self::generate_for(spiffe_id)
}
pub fn from_stored(credential: PqcCredential, signing_key_bytes: &[u8]) -> Result<Self> {
let signing_key = lupine::sign::HybridSigningKey65::from_bytes(signing_key_bytes)?;
let derived_vk_bytes = signing_key.verifying_key().to_bytes();
if derived_vk_bytes != credential.verifying_key_bytes {
return Err(Error::KeyCredentialMismatch);
}
let spiffe_id = credential.spiffe_id.clone();
Ok(AgentIdentity {
spiffe_id,
signing_key,
credential,
})
}
pub fn spiffe_id(&self) -> &SpiffeId {
&self.spiffe_id
}
pub fn credential(&self) -> PqcCredential {
self.credential.clone()
}
pub fn signing_key_bytes(&self) -> Vec<u8> {
self.signing_key.to_bytes()
}
pub fn sign(&self, data: &[u8]) -> Result<Vec<u8>> {
lupine::easy::sign(&self.signing_key, data)
.map_err(|_e| Error::Crypto(lupine_core::Error::Signing))
}
pub fn sign_with_domain(&self, domain: u8, payload: &[u8]) -> Result<Vec<u8>> {
let mut tagged = Vec::with_capacity(1 + payload.len());
tagged.push(domain);
tagged.extend_from_slice(payload);
self.sign(&tagged)
}
pub fn verify_with_domain(
verifying_key_bytes: &[u8],
domain: u8,
payload: &[u8],
signature: &[u8],
) -> Result<bool> {
let mut tagged = Vec::with_capacity(1 + payload.len());
tagged.push(domain);
tagged.extend_from_slice(payload);
let vk = lupine::sign::HybridVerifyingKey65::from_bytes(verifying_key_bytes)?;
lupine::easy::verify(&vk, &tagged, signature)
.map_err(|_e| Error::Crypto(lupine_core::Error::Verification))
}
pub fn verify(&self, data: &[u8], signature: &[u8]) -> Result<bool> {
let vk = self.signing_key.verifying_key();
lupine::easy::verify(&vk, data, signature)
.map_err(|_e| Error::Crypto(lupine_core::Error::Verification))
}
pub fn verify_peer(peer_credential: &PqcCredential) -> Result<()> {
if peer_credential.is_expired() {
return Err(Error::ChainVerificationFailed(format!(
"peer credential for {} is expired",
peer_credential.spiffe_id
)));
}
lupine::sign::HybridVerifyingKey65::from_bytes(&peer_credential.verifying_key_bytes)?;
Ok(())
}
pub fn is_expired(&self) -> bool {
self.credential.is_expired()
}
pub fn rotate(self) -> Result<(AgentIdentity, AgentIdentity)> {
let new_identity = Self::generate_for(self.spiffe_id.clone())?;
Ok((new_identity, self))
}
pub fn revoke(&self) -> Result<RevocationStatement> {
let cred_bytes = self.credential.to_bytes()?;
let revoked_at = Utc::now();
let ts_secs = revoked_at.timestamp().to_le_bytes();
let mut payload = cred_bytes.clone();
payload.extend_from_slice(&ts_secs);
let signature = self.sign_with_domain(DOMAIN_REVOCATION, &payload)?;
Ok(RevocationStatement {
target_credential_bytes: cred_bytes,
revoked_at,
signature,
})
}
fn generate_for(spiffe_id: SpiffeId) -> Result<Self> {
let keypair = lupine::easy::generate_keys()
.map_err(|_| Error::Crypto(lupine_core::Error::KeyGeneration))?;
let verifying_key_bytes = keypair.sign_pk.to_bytes();
let now = Utc::now();
let credential = PqcCredential {
spiffe_id: spiffe_id.clone(),
algo: CREDENTIAL_ALGO_V1,
verifying_key_bytes,
created_at: now,
expires_at: now + Duration::days(DEFAULT_VALIDITY_DAYS),
};
Ok(AgentIdentity {
spiffe_id,
signing_key: keypair.sign_sk,
credential,
})
}
}
impl fmt::Debug for AgentIdentity {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("AgentIdentity")
.field("spiffe_id", &self.spiffe_id)
.field("signing_key", &"<redacted>")
.finish()
}
}
pub fn save_signing_key(path: &std::path::Path, key_bytes: &[u8]) -> Result<()> {
use std::fs::OpenOptions;
use std::io::Write;
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
let mut f = OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.mode(0o600)
.open(path)?;
f.write_all(key_bytes)?;
}
#[cfg(not(unix))]
{
let mut f = OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.open(path)?;
f.write_all(key_bytes)?;
}
Ok(())
}
pub fn load_signing_key(path: &std::path::Path) -> Result<Vec<u8>> {
#[cfg(unix)]
{
extern "C" {
fn geteuid() -> u32;
}
use std::os::unix::fs::MetadataExt;
let meta = std::fs::metadata(path)?;
if meta.mode() & 0o177 != 0 {
return Err(Error::InsecureKeyPermissions);
}
let euid = unsafe { geteuid() };
if meta.uid() != euid {
return Err(Error::InsecureKeyOwner);
}
}
Ok(std::fs::read(path)?)
}
#[cfg(test)]
mod tests {
use super::*;
fn with_large_stack<F: FnOnce() + Send + 'static>(f: F) {
std::thread::Builder::new()
.stack_size(32 * 1024 * 1024)
.spawn(f)
.expect("thread spawn failed")
.join()
.expect("thread panicked");
}
#[test]
fn spiffe_id_parse_valid() {
let id = SpiffeId::parse("spiffe://example.com/agent/worker").unwrap();
assert_eq!(id.trust_domain(), "example.com");
assert_eq!(id.workload_path(), "/agent/worker");
assert_eq!(id.as_str(), "spiffe://example.com/agent/worker");
}
#[test]
fn spiffe_id_new_builds_uri() {
let id = SpiffeId::new("corp.internal", "orchestrator/main").unwrap();
assert_eq!(id.as_str(), "spiffe://corp.internal/orchestrator/main");
assert_eq!(id.trust_domain(), "corp.internal");
assert_eq!(id.workload_path(), "/orchestrator/main");
}
#[test]
fn spiffe_id_display() {
let id = SpiffeId::new("example.com", "agent/1").unwrap();
assert_eq!(id.to_string(), "spiffe://example.com/agent/1");
}
#[test]
fn spiffe_id_reject_no_prefix() {
assert!(SpiffeId::parse("http://example.com/agent").is_err());
assert!(SpiffeId::parse("example.com/agent").is_err());
}
#[test]
fn spiffe_id_reject_empty_trust_domain() {
assert!(SpiffeId::parse("spiffe:///agent").is_err());
assert!(SpiffeId::parse("spiffe://").is_err());
}
#[test]
fn spiffe_id_reject_empty_path() {
assert!(SpiffeId::parse("spiffe://example.com").is_err());
assert!(SpiffeId::parse("spiffe://example.com/").is_err());
}
#[test]
fn spiffe_id_reject_query_and_fragment() {
assert!(SpiffeId::parse("spiffe://example.com/agent?x=1").is_err());
assert!(SpiffeId::parse("spiffe://example.com/agent#frag").is_err());
}
#[test]
fn spiffe_id_reject_invalid_trust_domain_chars() {
assert!(SpiffeId::parse("spiffe://bad_domain/agent").is_err());
assert!(SpiffeId::parse("spiffe://bad domain/agent").is_err());
}
#[test]
fn spiffe_id_from_str() {
let id: SpiffeId = "spiffe://example.com/foo/bar".parse().unwrap();
assert_eq!(id.trust_domain(), "example.com");
}
#[test]
fn spiffe_id_serialize_roundtrip() {
let id = SpiffeId::new("example.com", "agent/1").unwrap();
let bytes = bincode::serialize(&id).unwrap();
let id2: SpiffeId = bincode::deserialize(&bytes).unwrap();
assert_eq!(id, id2);
}
#[test]
fn agent_identity_new_and_sign_verify() {
with_large_stack(|| {
let identity = AgentIdentity::new("example.com", "agent/test").unwrap();
let data = b"hello okami";
let sig = identity.sign(data).unwrap();
assert!(identity.verify(data, &sig).unwrap());
});
}
#[test]
fn agent_identity_wrong_data_fails_verify() {
with_large_stack(|| {
let identity = AgentIdentity::new("example.com", "agent/test").unwrap();
let sig = identity.sign(b"original").unwrap();
assert!(!identity.verify(b"tampered", &sig).unwrap());
});
}
#[test]
fn agent_identity_credential_is_not_expired_initially() {
with_large_stack(|| {
let identity = AgentIdentity::new("example.com", "agent/test").unwrap();
assert!(!identity.is_expired());
let cred = identity.credential();
assert!(!cred.is_expired());
});
}
#[test]
fn agent_identity_spiffe_id_matches() {
with_large_stack(|| {
let identity = AgentIdentity::new("example.com", "agent/test").unwrap();
assert_eq!(
identity.spiffe_id().as_str(),
"spiffe://example.com/agent/test"
);
});
}
#[test]
fn agent_identity_credential_has_correct_algo() {
with_large_stack(|| {
let identity = AgentIdentity::new("example.com", "agent/test").unwrap();
let cred = identity.credential();
assert_eq!(cred.algo, CREDENTIAL_ALGO_V1);
});
}
#[test]
fn agent_identity_from_stored_roundtrip() {
with_large_stack(|| {
let identity = AgentIdentity::new("example.com", "agent/roundtrip").unwrap();
let credential = identity.credential();
let key_bytes = identity.signing_key_bytes();
let identity2 = AgentIdentity::from_stored(credential, &key_bytes).unwrap();
let data = b"round-trip test";
let sig = identity2.sign(data).unwrap();
assert!(identity2.verify(data, &sig).unwrap());
let sig1 = identity.sign(data).unwrap();
let sig2 = identity2.sign(data).unwrap();
assert_eq!(
sig1, sig2,
"deterministic signing: same key must produce same sig"
);
});
}
#[test]
fn from_stored_preserves_credential_timestamps() {
with_large_stack(|| {
let identity = AgentIdentity::new("example.com", "agent/ts-preserve").unwrap();
let credential = identity.credential();
let original_created_at = credential.created_at;
let original_expires_at = credential.expires_at;
let key_bytes = identity.signing_key_bytes();
std::thread::sleep(std::time::Duration::from_millis(1));
let loaded = AgentIdentity::from_stored(credential, &key_bytes).unwrap();
let loaded_cred = loaded.credential();
assert_eq!(
loaded_cred.created_at, original_created_at,
"created_at must be preserved from on-disk credential, not re-minted"
);
assert_eq!(
loaded_cred.expires_at, original_expires_at,
"expires_at must be preserved from on-disk credential, not re-minted"
);
});
}
#[test]
fn from_stored_rejects_mismatched_key_and_credential() {
with_large_stack(|| {
let identity_a = AgentIdentity::new("example.com", "agent/a").unwrap();
let identity_b = AgentIdentity::new("example.com", "agent/b").unwrap();
let credential_a = identity_a.credential();
let key_bytes_b = identity_b.signing_key_bytes();
let result = AgentIdentity::from_stored(credential_a, &key_bytes_b);
assert!(
matches!(result, Err(Error::KeyCredentialMismatch)),
"mismatched key/credential must return KeyCredentialMismatch, got: {result:?}"
);
});
}
#[test]
fn from_stored_roundtrip_with_credential_sign_verify() {
with_large_stack(|| {
let identity = AgentIdentity::new("example.com", "agent/full-roundtrip").unwrap();
let credential = identity.credential();
let cred_bytes = credential.to_bytes().unwrap();
let key_bytes = identity.signing_key_bytes();
let restored_cred = PqcCredential::from_bytes(&cred_bytes).unwrap();
let loaded = AgentIdentity::from_stored(restored_cred, &key_bytes).unwrap();
let data = b"full-roundtrip payload";
let sig = loaded.sign(data).unwrap();
assert!(loaded.verify(data, &sig).unwrap(), "signature must verify");
let loaded_cred_bytes = loaded.credential().to_bytes().unwrap();
assert_eq!(
cred_bytes, loaded_cred_bytes,
"serialized credential bytes must be identical after round-trip"
);
});
}
#[test]
fn agent_identity_verify_peer_valid() {
with_large_stack(|| {
let identity = AgentIdentity::new("example.com", "agent/peer").unwrap();
let cred = identity.credential();
AgentIdentity::verify_peer(&cred).unwrap();
});
}
#[test]
fn agent_identity_verify_peer_expired_fails() {
with_large_stack(|| {
let identity = AgentIdentity::new("example.com", "agent/peer").unwrap();
let mut cred = identity.credential();
cred.expires_at = Utc::now() - Duration::seconds(1);
let result = AgentIdentity::verify_peer(&cred);
assert!(matches!(result, Err(Error::ChainVerificationFailed(_))));
});
}
#[test]
fn agent_identity_rotate() {
with_large_stack(|| {
let identity = AgentIdentity::new("example.com", "agent/rotate").unwrap();
let old_key_bytes = identity.signing_key_bytes();
let (new_identity, old_identity) = identity.rotate().unwrap();
assert_eq!(new_identity.spiffe_id(), old_identity.spiffe_id());
assert_ne!(new_identity.signing_key_bytes(), old_key_bytes);
assert_eq!(old_identity.signing_key_bytes(), old_key_bytes);
});
}
#[test]
fn agent_identity_revoke_produces_statement() {
with_large_stack(|| {
let identity = AgentIdentity::new("example.com", "agent/revoke").unwrap();
let stmt = identity.revoke().unwrap();
assert!(!stmt.signature.is_empty());
assert!(!stmt.target_credential_bytes.is_empty());
});
}
#[test]
fn pqc_credential_serialize_roundtrip() {
with_large_stack(|| {
let identity = AgentIdentity::new("example.com", "agent/cred").unwrap();
let cred = identity.credential();
let bytes = cred.to_bytes().unwrap();
let cred2 = PqcCredential::from_bytes(&bytes).unwrap();
assert_eq!(cred.spiffe_id, cred2.spiffe_id);
assert_eq!(cred.algo, cred2.algo);
assert_eq!(cred.verifying_key_bytes, cred2.verifying_key_bytes);
});
}
#[test]
fn pqc_credential_is_valid_at() {
with_large_stack(|| {
let identity = AgentIdentity::new("example.com", "agent/valid").unwrap();
let cred = identity.credential();
assert!(cred.is_valid_at(Utc::now()));
assert!(!cred.is_valid_at(Utc::now() + Duration::days(400)));
assert!(!cred.is_valid_at(Utc::now() - Duration::days(1)));
});
}
#[cfg(unix)]
#[test]
fn save_and_load_signing_key_roundtrip() {
with_large_stack(|| {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("signing.key");
let identity = AgentIdentity::new("example.com", "agent/fileio").unwrap();
let key_bytes = identity.signing_key_bytes();
save_signing_key(&path, &key_bytes).unwrap();
let loaded = load_signing_key(&path).unwrap();
assert_eq!(key_bytes, loaded);
});
}
#[cfg(unix)]
#[test]
fn load_signing_key_rejects_wide_permissions() {
with_large_stack(|| {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("insecure.key");
std::fs::write(&path, b"fake key bytes").unwrap();
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
let result = load_signing_key(&path);
assert!(matches!(result, Err(Error::InsecureKeyPermissions)));
});
}
#[cfg(unix)]
#[test]
fn load_signing_key_accepts_correct_owner() {
with_large_stack(|| {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("owned.key");
let identity = AgentIdentity::new("example.com", "agent/uid-check").unwrap();
let key_bytes = identity.signing_key_bytes();
save_signing_key(&path, &key_bytes).unwrap();
let loaded = load_signing_key(&path).unwrap();
assert_eq!(key_bytes, loaded);
});
}
#[test]
fn insecure_key_owner_error_variant() {
let e = Error::InsecureKeyOwner;
assert!(
e.to_string().contains("owner"),
"InsecureKeyOwner message must mention 'owner': {e}"
);
}
#[test]
fn domain_sign_verify_roundtrip() {
with_large_stack(|| {
let identity = AgentIdentity::new("example.com", "agent/domain-rt").unwrap();
let vk_bytes = identity.credential().verifying_key_bytes.clone();
let payload = b"test payload";
for domain in [DOMAIN_TOKEN, DOMAIN_AUDIT, DOMAIN_REVOCATION] {
let sig = identity.sign_with_domain(domain, payload).unwrap();
let valid =
AgentIdentity::verify_with_domain(&vk_bytes, domain, payload, &sig).unwrap();
assert!(valid, "domain={domain:#04x} roundtrip must verify");
}
});
}
#[test]
fn domain_token_sig_does_not_verify_as_audit() {
with_large_stack(|| {
let identity = AgentIdentity::new("example.com", "agent/cross-proto").unwrap();
let vk_bytes = identity.credential().verifying_key_bytes.clone();
let payload = b"shared payload bytes";
let sig = identity.sign_with_domain(DOMAIN_TOKEN, payload).unwrap();
let valid =
AgentIdentity::verify_with_domain(&vk_bytes, DOMAIN_AUDIT, payload, &sig).unwrap();
assert!(!valid, "token signature must not verify under audit domain");
});
}
#[test]
fn domain_audit_sig_does_not_verify_as_token() {
with_large_stack(|| {
let identity = AgentIdentity::new("example.com", "agent/cross-proto2").unwrap();
let vk_bytes = identity.credential().verifying_key_bytes.clone();
let payload = b"shared payload bytes";
let sig = identity.sign_with_domain(DOMAIN_AUDIT, payload).unwrap();
let valid =
AgentIdentity::verify_with_domain(&vk_bytes, DOMAIN_TOKEN, payload, &sig).unwrap();
assert!(!valid, "audit signature must not verify under token domain");
});
}
#[test]
fn domain_revocation_sig_isolated() {
with_large_stack(|| {
let identity = AgentIdentity::new("example.com", "agent/cross-proto3").unwrap();
let vk_bytes = identity.credential().verifying_key_bytes.clone();
let payload = b"revocation payload";
let sig = identity
.sign_with_domain(DOMAIN_REVOCATION, payload)
.unwrap();
for other_domain in [DOMAIN_TOKEN, DOMAIN_AUDIT] {
let valid =
AgentIdentity::verify_with_domain(&vk_bytes, other_domain, payload, &sig)
.unwrap();
assert!(
!valid,
"revocation sig must not verify under domain={other_domain:#04x}"
);
}
});
}
#[test]
fn revocation_verify_round_trip() {
with_large_stack(|| {
let identity = AgentIdentity::new("example.com", "agent/revoke-rt").unwrap();
let cred_bytes = identity.credential().to_bytes().unwrap();
let vk_bytes = identity.credential().verifying_key_bytes.clone();
let stmt = identity.revoke().unwrap();
let result = stmt.verify(&vk_bytes, &cred_bytes).unwrap();
assert!(
result,
"verify should return true for a valid revocation statement"
);
});
}
#[test]
fn revocation_verify_wrong_key() {
with_large_stack(|| {
let identity_a = AgentIdentity::new("example.com", "agent/revoke-a").unwrap();
let identity_b = AgentIdentity::new("example.com", "agent/revoke-b").unwrap();
let cred_a_bytes = identity_a.credential().to_bytes().unwrap();
let vk_b_bytes = identity_b.credential().verifying_key_bytes.clone();
let stmt = identity_a.revoke().unwrap();
let result = stmt.verify(&vk_b_bytes, &cred_a_bytes).unwrap();
assert!(
!result,
"verify should return false when the wrong key is used"
);
});
}
#[test]
fn revocation_verify_tampered_target_bytes() {
with_large_stack(|| {
let identity = AgentIdentity::new("example.com", "agent/revoke-tamper").unwrap();
let cred_bytes = identity.credential().to_bytes().unwrap();
let vk_bytes = identity.credential().verifying_key_bytes.clone();
let mut stmt = identity.revoke().unwrap();
stmt.target_credential_bytes[0] ^= 0x01;
let result = stmt.verify(&vk_bytes, &cred_bytes).unwrap();
assert!(
!result,
"verify should return false when target_credential_bytes are tampered"
);
});
}
#[test]
fn revocation_verify_wrong_claimed_bytes() {
with_large_stack(|| {
let identity = AgentIdentity::new("example.com", "agent/revoke-mismatch").unwrap();
let vk_bytes = identity.credential().verifying_key_bytes.clone();
let stmt = identity.revoke().unwrap();
let wrong_bytes = b"this is not the right credential bytes";
let result = stmt.verify(&vk_bytes, wrong_bytes).unwrap();
assert!(
!result,
"verify should return false when claimed_credential_bytes do not match"
);
});
}
#[test]
fn revocation_verify_cross_protocol_signature_rejected() {
with_large_stack(|| {
let identity = AgentIdentity::new("example.com", "agent/revoke-xproto").unwrap();
let vk_bytes = identity.credential().verifying_key_bytes.clone();
let stmt = identity.revoke().unwrap();
let ts_secs = stmt.revoked_at.timestamp().to_le_bytes();
let mut token_payload = stmt.target_credential_bytes.clone();
token_payload.extend_from_slice(&ts_secs);
let cross_sig = identity
.sign_with_domain(DOMAIN_TOKEN, &token_payload)
.unwrap();
let tampered = RevocationStatement {
target_credential_bytes: stmt.target_credential_bytes.clone(),
revoked_at: stmt.revoked_at,
signature: cross_sig,
};
let cred_bytes = stmt.target_credential_bytes.clone();
let result = tampered.verify(&vk_bytes, &cred_bytes).unwrap();
assert!(
!result,
"verify must return false for a cross-protocol (DOMAIN_TOKEN) signature"
);
});
}
#[test]
fn pqc_credential_from_bytes_rejects_oversized_length_prefix() {
let mut crafted = vec![0xFFu8; 8];
crafted.extend_from_slice(&[0u8; 16]); let result = PqcCredential::from_bytes(&crafted);
assert!(
result.is_err(),
"oversized length prefix must be rejected, got Ok"
);
assert!(
matches!(result, Err(Error::Serialization(_))),
"expected Serialization error, got: {:?}",
result
);
}
}