use crate::groups::policy::GroupPolicySummary;
use crate::groups::state_commit::{ApplyError, CARD_SIGNATURE_DOMAIN, DEFAULT_CARD_TTL_SECS};
use crate::identity::AgentKeypair;
use ant_quic::crypto::raw_public_keys::pqc::{
sign_with_ml_dsa, verify_with_ml_dsa, MlDsaSignature,
};
use ant_quic::MlDsaPublicKey;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct GroupCard {
pub group_id: String,
pub name: String,
pub description: String,
#[serde(default)]
pub avatar_url: Option<String>,
#[serde(default)]
pub banner_url: Option<String>,
#[serde(default)]
pub tags: Vec<String>,
pub policy_summary: GroupPolicySummary,
pub owner_agent_id: String,
pub admin_count: u32,
pub member_count: u32,
pub created_at: u64,
pub updated_at: u64,
pub request_access_enabled: bool,
#[serde(default)]
pub metadata_topic: Option<String>,
#[serde(default)]
pub revision: u64,
#[serde(default)]
pub state_hash: String,
#[serde(default)]
pub prev_state_hash: Option<String>,
#[serde(default)]
pub issued_at: u64,
#[serde(default)]
pub expires_at: u64,
#[serde(default)]
pub authority_agent_id: String,
#[serde(default)]
pub authority_public_key: String,
#[serde(default)]
pub withdrawn: bool,
#[serde(default)]
pub signature: String,
}
impl GroupCard {
fn signable_bytes_legacy(&self) -> Vec<u8> {
let mut buf = Vec::with_capacity(512);
buf.extend_from_slice(CARD_SIGNATURE_DOMAIN);
push_len_prefixed(&mut buf, self.group_id.as_bytes());
buf.extend_from_slice(&self.revision.to_le_bytes());
push_len_prefixed(&mut buf, self.state_hash.as_bytes());
push_len_prefixed(
&mut buf,
self.prev_state_hash.as_deref().unwrap_or("").as_bytes(),
);
buf.extend_from_slice(&self.issued_at.to_le_bytes());
buf.extend_from_slice(&self.expires_at.to_le_bytes());
push_len_prefixed(&mut buf, self.name.as_bytes());
push_len_prefixed(&mut buf, self.description.as_bytes());
push_len_prefixed(
&mut buf,
self.avatar_url.as_deref().unwrap_or("").as_bytes(),
);
push_len_prefixed(
&mut buf,
self.banner_url.as_deref().unwrap_or("").as_bytes(),
);
let mut tags = self.tags.clone();
tags.sort();
tags.dedup();
buf.extend_from_slice(&(tags.len() as u32).to_le_bytes());
for t in &tags {
push_len_prefixed(&mut buf, t.as_bytes());
}
let policy_bytes = bincode::serialize(&self.policy_summary).unwrap_or_default();
push_len_prefixed(&mut buf, &policy_bytes);
push_len_prefixed(&mut buf, self.owner_agent_id.as_bytes());
buf.extend_from_slice(&self.admin_count.to_le_bytes());
buf.extend_from_slice(&self.member_count.to_le_bytes());
buf.extend_from_slice(&self.created_at.to_le_bytes());
buf.extend_from_slice(&self.updated_at.to_le_bytes());
buf.push(if self.request_access_enabled { 1 } else { 0 });
buf.push(if self.withdrawn { 1 } else { 0 });
push_len_prefixed(&mut buf, self.authority_agent_id.as_bytes());
push_len_prefixed(&mut buf, self.authority_public_key.as_bytes());
buf
}
#[must_use]
pub fn signable_bytes(&self) -> Vec<u8> {
let mut buf = self.signable_bytes_legacy();
push_len_prefixed(
&mut buf,
self.metadata_topic.as_deref().unwrap_or("").as_bytes(),
);
buf
}
pub fn sign(&mut self, keypair: &AgentKeypair) -> Result<(), ApplyError> {
self.authority_agent_id = hex::encode(keypair.agent_id().as_bytes());
self.authority_public_key = hex::encode(keypair.public_key().as_bytes());
self.signature = String::new();
let sig = sign_with_ml_dsa(keypair.secret_key(), &self.signable_bytes())
.map_err(|e| ApplyError::InvalidSignature(format!("card sign: {e:?}")))?;
self.signature = hex::encode(sig.as_bytes());
Ok(())
}
pub fn verify_signature(&self) -> Result<(), ApplyError> {
if self.signature.is_empty() || self.authority_public_key.is_empty() {
return Err(ApplyError::InvalidSignature("missing signature".into()));
}
let pubkey_bytes = hex::decode(&self.authority_public_key)
.map_err(|e| ApplyError::InvalidSignature(format!("bad pubkey hex: {e}")))?;
let pubkey = MlDsaPublicKey::from_bytes(&pubkey_bytes)
.map_err(|e| ApplyError::InvalidSignature(format!("bad pubkey: {e:?}")))?;
let derived = hex::encode(ant_quic::derive_peer_id_from_public_key(&pubkey).0);
if derived != self.authority_agent_id {
return Err(ApplyError::InvalidSignature(format!(
"authority_agent_id {} != derived {}",
self.authority_agent_id, derived
)));
}
let sig_bytes = hex::decode(&self.signature)
.map_err(|e| ApplyError::InvalidSignature(format!("bad sig hex: {e}")))?;
let sig = MlDsaSignature::from_bytes(&sig_bytes)
.map_err(|e| ApplyError::InvalidSignature(format!("bad sig: {e:?}")))?;
if verify_with_ml_dsa(&pubkey, &self.signable_bytes(), &sig).is_ok() {
return Ok(());
}
if self.metadata_topic.is_none()
&& verify_with_ml_dsa(&pubkey, &self.signable_bytes_legacy(), &sig).is_ok()
{
return Ok(());
}
Err(ApplyError::InvalidSignature(
"card verify failed for both v2 and legacy domains".into(),
))
}
#[must_use]
pub fn default_ttl_secs() -> u64 {
DEFAULT_CARD_TTL_SECS
}
#[must_use]
pub fn supersedes(&self, other: &GroupCard) -> bool {
if self.group_id != other.group_id {
return false;
}
self.revision > other.revision
|| (self.revision == other.revision && self.issued_at > other.issued_at)
}
}
fn push_len_prefixed(buf: &mut Vec<u8>, bytes: &[u8]) {
buf.extend_from_slice(&(bytes.len() as u32).to_le_bytes());
buf.extend_from_slice(bytes);
}
#[cfg(test)]
mod tests {
use super::*;
use crate::groups::policy::{
GroupAdmission, GroupConfidentiality, GroupDiscoverability, GroupReadAccess,
GroupWriteAccess,
};
fn sample_summary() -> GroupPolicySummary {
GroupPolicySummary {
discoverability: GroupDiscoverability::PublicDirectory,
admission: GroupAdmission::RequestAccess,
confidentiality: GroupConfidentiality::MlsEncrypted,
read_access: GroupReadAccess::MembersOnly,
write_access: GroupWriteAccess::MembersOnly,
}
}
fn sample_card() -> GroupCard {
GroupCard {
group_id: "abcd".repeat(16),
name: "Test".into(),
description: "desc".into(),
avatar_url: None,
banner_url: None,
tags: vec!["rust".into()],
policy_summary: sample_summary(),
owner_agent_id: "ff".repeat(32),
admin_count: 1,
member_count: 5,
created_at: 0,
updated_at: 0,
request_access_enabled: true,
metadata_topic: None,
revision: 1,
state_hash: "sh-1".into(),
prev_state_hash: None,
issued_at: 100,
expires_at: 200,
authority_agent_id: String::new(),
authority_public_key: String::new(),
withdrawn: false,
signature: String::new(),
}
}
#[test]
fn card_roundtrip() {
let c = sample_card();
let json = serde_json::to_string(&c).unwrap();
let c2: GroupCard = serde_json::from_str(&json).unwrap();
assert_eq!(c, c2);
}
#[test]
fn card_sign_and_verify_roundtrip() {
let kp = AgentKeypair::generate().unwrap();
let mut c = sample_card();
c.sign(&kp).unwrap();
assert!(!c.signature.is_empty());
c.verify_signature().unwrap();
}
#[test]
fn card_signature_detects_tamper() {
let kp = AgentKeypair::generate().unwrap();
let mut c = sample_card();
c.sign(&kp).unwrap();
let mut bad = c.clone();
bad.name = "Tampered".into();
assert!(bad.verify_signature().is_err());
let mut bad = c.clone();
bad.revision = 999;
assert!(bad.verify_signature().is_err());
let mut bad = c.clone();
bad.withdrawn = true;
assert!(bad.verify_signature().is_err());
}
#[test]
fn card_signature_rejects_wrong_authority() {
let kp1 = AgentKeypair::generate().unwrap();
let kp2 = AgentKeypair::generate().unwrap();
let mut c = sample_card();
c.sign(&kp1).unwrap();
c.authority_agent_id = hex::encode(kp2.agent_id().as_bytes());
assert!(c.verify_signature().is_err());
}
#[test]
fn supersedes_by_revision() {
let kp = AgentKeypair::generate().unwrap();
let mut lo = sample_card();
lo.revision = 1;
lo.sign(&kp).unwrap();
let mut hi = sample_card();
hi.revision = 2;
hi.sign(&kp).unwrap();
assert!(hi.supersedes(&lo));
assert!(!lo.supersedes(&hi));
}
#[test]
fn supersedes_by_issued_at_on_revision_tie() {
let kp = AgentKeypair::generate().unwrap();
let mut a = sample_card();
a.revision = 1;
a.issued_at = 100;
a.sign(&kp).unwrap();
let mut b = sample_card();
b.revision = 1;
b.issued_at = 200;
b.sign(&kp).unwrap();
assert!(b.supersedes(&a));
assert!(!a.supersedes(&b));
}
#[test]
fn supersedes_requires_same_group_id() {
let kp = AgentKeypair::generate().unwrap();
let mut a = sample_card();
a.revision = 1;
a.sign(&kp).unwrap();
let mut b = sample_card();
b.revision = 2;
b.group_id = "different".into();
b.sign(&kp).unwrap();
assert!(!b.supersedes(&a));
}
#[test]
fn unsigned_card_verify_fails() {
let c = sample_card();
assert!(c.verify_signature().is_err());
}
#[test]
fn metadata_topic_is_bound_in_v2_signature() {
let kp = AgentKeypair::generate().unwrap();
let mut c = sample_card();
c.metadata_topic = Some("x0x.group.test.meta".into());
c.sign(&kp).unwrap();
c.verify_signature().unwrap();
let mut bad = c.clone();
bad.metadata_topic = Some("x0x.group.evil.meta".into());
assert!(bad.verify_signature().is_err());
}
#[test]
fn legacy_card_without_metadata_topic_still_verifies() {
let kp = AgentKeypair::generate().unwrap();
let mut c = sample_card();
c.authority_agent_id = hex::encode(kp.agent_id().as_bytes());
c.authority_public_key = hex::encode(kp.public_key().as_bytes());
let sig = sign_with_ml_dsa(kp.secret_key(), &c.signable_bytes_legacy()).unwrap();
c.signature = hex::encode(sig.as_bytes());
c.verify_signature().unwrap();
}
}