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;
#[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> {
bincode::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>,
}
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(spiffe_id_str: &str, signing_key_bytes: &[u8]) -> Result<Self> {
let spiffe_id = SpiffeId::parse(spiffe_id_str)?;
let signing_key = lupine::sign::HybridSigningKey65::from_bytes(signing_key_bytes)?;
let verifying_key = signing_key.verifying_key();
let verifying_key_bytes = verifying_key.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,
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 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 to_sign = cred_bytes.clone();
to_sign.extend_from_slice(&ts_secs);
let signature = self.sign(&to_sign)?;
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)]
{
use std::os::unix::fs::MetadataExt;
let meta = std::fs::metadata(path)?;
if meta.mode() & 0o177 != 0 {
return Err(Error::InsecureKeyPermissions);
}
}
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 spiffe_str = identity.spiffe_id().to_string();
let key_bytes = identity.signing_key_bytes();
let identity2 = AgentIdentity::from_stored(&spiffe_str, &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 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)));
});
}
}