use alloc::collections::{BTreeMap, BTreeSet};
use alloc::string::String;
use alloc::vec;
use alloc::vec::Vec;
use serde::{Deserialize, Serialize};
use crate::claim::{Claim, ClaimRef};
use crate::error::CompositionError;
use crate::primitives::{
evidence::{Evidence, EvidenceEnvelope, EvidenceScheme, StarkProofEnvelope},
revelation_mask::RevelationMask,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum OperatorTag {
Conjunction,
Delegation,
Aggregation,
Restriction,
Revocation,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum OperatorBody {
Conjunction(ConjunctionBody),
Delegation(DelegationBody),
Aggregation(AggregationBody),
Restriction(RestrictionBody),
Revocation(RevocationBody),
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ConjunctionBody {
pub left: ClaimRef,
pub right: ClaimRef,
#[serde(rename = "linkage-proof", default, skip_serializing_if = "Option::is_none")]
pub linkage_proof: Option<alloc::vec::Vec<u8>>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DelegationBody {
pub parent: ClaimRef,
pub authority: Authority,
pub scope: DelegationScope,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Authority {
#[serde(rename = "type")]
pub authority_type: AuthorityType,
pub identifier: AuthorityId,
#[serde(rename = "key-ref", default, skip_serializing_if = "Option::is_none", with = "opt_bytes")]
pub key_ref: Option<Vec<u8>>,
#[serde(rename = "trust-root", default, skip_serializing_if = "Option::is_none")]
pub trust_root: Option<crate::primitives::anchor::AnchorEntry>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum AuthorityType {
#[serde(rename = "x509-ca")]
X509Ca,
#[serde(rename = "did-method")]
DidMethod,
#[serde(rename = "starknet-registry")]
StarknetRegistry,
#[serde(rename = "ietf-ta")]
IetfTa,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(untagged)]
pub enum AuthorityId {
Bytes(#[serde(with = "serde_bytes")] Vec<u8>),
Text(String),
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct DelegationScope {
#[serde(rename = "predicate-types", default, skip_serializing_if = "Option::is_none")]
pub predicate_types: Option<Vec<crate::primitives::predicate::PredicateType>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub domains: Option<Vec<String>>,
#[serde(rename = "max-depth", default, skip_serializing_if = "Option::is_none")]
pub max_depth: Option<u64>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AggregationBody {
pub count: u64,
#[serde(rename = "aggregated-evidence")]
pub aggregated_evidence: StarkProofEnvelope,
#[serde(rename = "issuer-bindings")]
pub issuer_bindings: Vec<IssuerBinding>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct IssuerBinding {
pub operand: ClaimRef,
#[serde(rename = "issuer-key", with = "serde_bytes")]
pub issuer_key: Vec<u8>,
#[serde(rename = "issuer-anchor", default, skip_serializing_if = "Option::is_none")]
pub issuer_anchor: Option<crate::primitives::anchor::AnchorEntry>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RestrictionBody {
pub source: ClaimRef,
pub mask: RevelationMask,
#[serde(rename = "monotonicity-proof", default, skip_serializing_if = "Option::is_none")]
pub monotonicity_proof: Option<alloc::vec::Vec<u8>>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RevocationBody {
pub source: ClaimRef,
#[serde(rename = "revoked-at")]
pub revoked_at: u64,
pub revoker: Authority,
pub proof: RevocationProof,
#[serde(rename = "reason-code", default, skip_serializing_if = "Option::is_none")]
pub reason_code: Option<RevocationReasonCode>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "mode", rename_all = "kebab-case")]
pub enum RevocationProof {
Nullifier {
#[serde(with = "serde_bytes")]
nullifier: Vec<u8>,
#[serde(rename = "anchor-entry")]
anchor_entry: crate::primitives::anchor::AnchorEntry,
},
StatusList {
#[serde(rename = "list-uri")]
list_uri: String,
#[serde(rename = "list-index")]
list_index: u64,
#[serde(default, skip_serializing_if = "Option::is_none", with = "opt_bytes")]
signature: Option<Vec<u8>>,
#[serde(rename = "batch-anchor", default, skip_serializing_if = "Option::is_none")]
batch_anchor: Option<crate::primitives::anchor::AnchorEntry>,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum RevocationReasonCode {
Unspecified,
KeyCompromise,
CaCompromise,
AffiliationChanged,
Superseded,
CessationOfOperation,
CertificateHold,
PrivilegeWithdrawn,
}
mod opt_bytes {
use alloc::vec::Vec;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
pub fn serialize<S: Serializer>(v: &Option<Vec<u8>>, s: S) -> Result<S::Ok, S::Error> {
match v {
Some(b) => serde_bytes::Bytes::new(b).serialize(s),
None => s.serialize_none(),
}
}
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Option<Vec<u8>>, D::Error> {
let opt: Option<serde_bytes::ByteBuf> = Option::deserialize(d)?;
Ok(opt.map(serde_bytes::ByteBuf::into_vec))
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CompositionRecord {
pub operator: OperatorTag,
pub operands: Vec<ClaimRef>,
pub depth: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub metadata: Option<BTreeMap<String, serde_json::Value>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub body: Option<OperatorBody>,
}
pub trait ClaimComposition {
fn conjunct(&self, other: &Claim) -> Result<Claim, CompositionError>;
fn delegate(&self, authority: &Authority, scope: DelegationScope)
-> Result<Claim, CompositionError>;
fn aggregate(
claims: &[Claim],
bindings: Vec<IssuerBinding>,
aggregated_evidence: StarkProofEnvelope,
) -> Result<Claim, CompositionError>;
fn restrict(&self, mask: &RevelationMask) -> Result<Claim, CompositionError>;
fn revoke(
&self,
revoker: Authority,
revoked_at: u64,
proof: RevocationProof,
reason: Option<RevocationReasonCode>,
) -> Result<Claim, CompositionError>;
}
impl ClaimComposition for Claim {
fn conjunct(&self, other: &Claim) -> Result<Claim, CompositionError> {
if self.subject != other.subject {
return Err(CompositionError::SubjectMismatch);
}
let temporal = self
.temporal_frame
.intersect(&other.temporal_frame)
.ok_or(CompositionError::TemporalDisjoint)?;
let anchor = self.anchor.union(&other.anchor);
let mask = merge_masks(&self.revelation_mask, &other.revelation_mask)?;
let left = self.claim_ref().expect("claim_ref encoding");
let right = other.claim_ref().expect("claim_ref encoding");
let body = OperatorBody::Conjunction(ConjunctionBody {
left: left.clone(),
right: right.clone(),
linkage_proof: None,
});
let depth = 1 + self.depth().max(other.depth());
let record = CompositionRecord {
operator: OperatorTag::Conjunction,
operands: vec![left, right],
depth,
metadata: None,
body: Some(body),
};
let claim = Claim {
subject: self.subject.clone(),
predicate: self.predicate.clone(),
evidence: self.evidence.clone(),
temporal_frame: temporal,
revelation_mask: mask,
anchor,
composition: Some(record),
extensions: None,
#[cfg(feature = "transcript-v2")]
transcript_version: crate::transcript_v2::TranscriptVersion::default(),
};
claim.validate()?;
Ok(claim)
}
fn delegate(
&self,
authority: &Authority,
scope: DelegationScope,
) -> Result<Claim, CompositionError> {
if let Some(parent_record) = &self.composition {
if matches!(parent_record.operator, OperatorTag::Delegation) {
if let Some(OperatorBody::Delegation(parent)) = &parent_record.body {
enforce_scope_subset(&parent.scope, &scope)?;
if let Some(max_depth) = parent.scope.max_depth {
if parent_record.depth >= max_depth {
return Err(CompositionError::ScopeOverflow);
}
}
}
}
}
let mut chain = BTreeSet::new();
let mut cursor = Some(self);
while let Some(c) = cursor {
let r = c.claim_ref().expect("claim_ref encoding");
if !chain.insert(r.digest.clone()) {
return Err(CompositionError::DelegationCycle);
}
cursor = None; if let Some(rec) = &c.composition {
if let Some(OperatorBody::Delegation(d)) = &rec.body {
if d.parent.digest == r.digest {
return Err(CompositionError::DelegationCycle);
}
}
}
}
let parent_ref = self.claim_ref().expect("claim_ref encoding");
let body = OperatorBody::Delegation(DelegationBody {
parent: parent_ref.clone(),
authority: authority.clone(),
scope,
});
let depth = 1 + self.depth();
let record = CompositionRecord {
operator: OperatorTag::Delegation,
operands: vec![parent_ref],
depth,
metadata: None,
body: Some(body),
};
Ok(Claim {
subject: self.subject.clone(),
predicate: self.predicate.clone(),
evidence: self.evidence.clone(),
temporal_frame: self.temporal_frame,
revelation_mask: self.revelation_mask.clone(),
anchor: self.anchor.clone(),
composition: Some(record),
extensions: None,
#[cfg(feature = "transcript-v2")]
transcript_version: crate::transcript_v2::TranscriptVersion::default(),
})
}
fn aggregate(
claims: &[Claim],
bindings: Vec<IssuerBinding>,
aggregated_evidence: StarkProofEnvelope,
) -> Result<Claim, CompositionError> {
if claims.len() < 2 {
return Err(CompositionError::AggregationTooFew);
}
if bindings.len() != claims.len() {
return Err(CompositionError::Invariant(
"issuer_bindings.len must equal operands.len",
));
}
let mut seen = BTreeSet::new();
for b in &bindings {
if !seen.insert(b.issuer_key.clone()) {
return Err(CompositionError::IssuerDuplicate);
}
}
let mut temporal = claims[0].temporal_frame;
let mut anchor = claims[0].anchor.clone();
for c in &claims[1..] {
temporal = temporal
.intersect(&c.temporal_frame)
.ok_or(CompositionError::TemporalDisjoint)?;
anchor = anchor.union(&c.anchor);
}
let operands: Vec<ClaimRef> = claims
.iter()
.map(|c| c.claim_ref().expect("claim_ref encoding"))
.collect();
let depth = 1 + claims.iter().map(Claim::depth).max().unwrap_or(0);
let body = OperatorBody::Aggregation(AggregationBody {
count: claims.len() as u64,
aggregated_evidence: aggregated_evidence.clone(),
issuer_bindings: bindings,
});
let evidence = Evidence::new(
EvidenceScheme::Stark,
aggregated_evidence.proof.clone(),
Some(EvidenceEnvelope::Stark(aggregated_evidence)),
)?;
Ok(Claim {
subject: claims[0].subject.clone(),
predicate: claims[0].predicate.clone(),
evidence,
temporal_frame: temporal,
revelation_mask: claims[0].revelation_mask.clone(),
anchor,
composition: Some(CompositionRecord {
operator: OperatorTag::Aggregation,
operands,
depth,
metadata: None,
body: Some(body),
}),
extensions: None,
#[cfg(feature = "transcript-v2")]
transcript_version: crate::transcript_v2::TranscriptVersion::default(),
})
}
fn restrict(&self, mask: &RevelationMask) -> Result<Claim, CompositionError> {
if !mask.refines(&self.revelation_mask) {
return Err(CompositionError::MaskNonMonotonic);
}
mask.validate_shape()?;
let source = self.claim_ref().expect("claim_ref encoding");
let body = OperatorBody::Restriction(RestrictionBody {
source: source.clone(),
mask: mask.clone(),
monotonicity_proof: None,
});
let depth = 1 + self.depth();
Ok(Claim {
subject: self.subject.clone(),
predicate: self.predicate.clone(),
evidence: self.evidence.clone(),
temporal_frame: self.temporal_frame,
revelation_mask: mask.clone(),
anchor: self.anchor.clone(),
composition: Some(CompositionRecord {
operator: OperatorTag::Restriction,
operands: vec![source],
depth,
metadata: None,
body: Some(body),
}),
extensions: None,
#[cfg(feature = "transcript-v2")]
transcript_version: crate::transcript_v2::TranscriptVersion::default(),
})
}
fn revoke(
&self,
revoker: Authority,
revoked_at: u64,
proof: RevocationProof,
reason: Option<RevocationReasonCode>,
) -> Result<Claim, CompositionError> {
match &proof {
RevocationProof::Nullifier { nullifier, .. } => {
if nullifier.len() != 32 {
return Err(CompositionError::Invariant(
"V-2: nullifier must be 32 bytes (Poseidon-felt252)",
));
}
}
RevocationProof::StatusList { list_uri, .. } => {
if list_uri.is_empty() {
return Err(CompositionError::Invariant(
"V-2: status-list revocation requires a non-empty list_uri",
));
}
}
}
if self.temporal_frame.is_revoked_at(revoked_at) {
return Err(CompositionError::AlreadyRevoked);
}
let source = self.claim_ref().expect("claim_ref encoding");
let body = OperatorBody::Revocation(RevocationBody {
source: source.clone(),
revoked_at,
revoker,
proof,
reason_code: reason,
});
let mut frame = self.temporal_frame;
frame.revoked_at = Some(revoked_at);
let depth = 1 + self.depth();
Ok(Claim {
subject: self.subject.clone(),
predicate: self.predicate.clone(),
evidence: self.evidence.clone(),
temporal_frame: frame,
revelation_mask: self.revelation_mask.clone(),
anchor: self.anchor.clone(),
composition: Some(CompositionRecord {
operator: OperatorTag::Revocation,
operands: vec![source],
depth,
metadata: None,
body: Some(body),
}),
extensions: None,
#[cfg(feature = "transcript-v2")]
transcript_version: crate::transcript_v2::TranscriptVersion::default(),
})
}
}
fn merge_masks(
a: &RevelationMask,
b: &RevelationMask,
) -> Result<RevelationMask, CompositionError> {
let mut disclosed = a.disclosed.clone();
for d in &b.disclosed {
if !disclosed.contains(d) {
disclosed.push(d.clone());
}
}
let mut committed = a.committed.clone();
for c in &b.committed {
if !committed.iter().any(|x| x.path == c.path) {
committed.push(c.clone());
}
}
RevelationMask::new(disclosed, committed, a.hash_alg.or(b.hash_alg))
}
fn enforce_scope_subset(
parent: &DelegationScope,
child: &DelegationScope,
) -> Result<(), CompositionError> {
if let (Some(parent_pt), Some(child_pt)) = (&parent.predicate_types, &child.predicate_types) {
let p: BTreeSet<_> = parent_pt.iter().collect();
for x in child_pt {
if !p.contains(x) {
return Err(CompositionError::ScopeOverflow);
}
}
}
if let (Some(parent_dom), Some(child_dom)) = (&parent.domains, &child.domains) {
for x in child_dom {
if !parent_dom.iter().any(|p| x.starts_with(p)) {
return Err(CompositionError::ScopeOverflow);
}
}
}
if let (Some(parent_md), Some(child_md)) = (parent.max_depth, child.max_depth) {
if child_md > parent_md {
return Err(CompositionError::ScopeOverflow);
}
}
Ok(())
}