1use alloc::collections::{BTreeMap, BTreeSet};
4use alloc::string::String;
5use alloc::vec;
6use alloc::vec::Vec;
7use serde::{Deserialize, Serialize};
8
9use crate::claim::{Claim, ClaimRef};
10use crate::error::CompositionError;
11use crate::primitives::{
12 evidence::{Evidence, EvidenceEnvelope, EvidenceScheme, StarkProofEnvelope},
13 revelation_mask::RevelationMask,
14};
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
18#[serde(rename_all = "kebab-case")]
19pub enum OperatorTag {
20 Conjunction,
22 Delegation,
24 Aggregation,
26 Restriction,
28 Revocation,
30}
31
32#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
34#[serde(untagged)]
35pub enum OperatorBody {
36 Conjunction(ConjunctionBody),
38 Delegation(DelegationBody),
40 Aggregation(AggregationBody),
42 Restriction(RestrictionBody),
44 Revocation(RevocationBody),
46}
47
48#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
50pub struct ConjunctionBody {
51 pub left: ClaimRef,
53 pub right: ClaimRef,
55 #[serde(rename = "linkage-proof", default, skip_serializing_if = "Option::is_none")]
57 pub linkage_proof: Option<alloc::vec::Vec<u8>>,
58}
59
60#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
62pub struct DelegationBody {
63 pub parent: ClaimRef,
65 pub authority: Authority,
67 pub scope: DelegationScope,
69}
70
71#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
73pub struct Authority {
74 #[serde(rename = "type")]
76 pub authority_type: AuthorityType,
77 pub identifier: AuthorityId,
79 #[serde(rename = "key-ref", default, skip_serializing_if = "Option::is_none", with = "opt_bytes")]
81 pub key_ref: Option<Vec<u8>>,
82 #[serde(rename = "trust-root", default, skip_serializing_if = "Option::is_none")]
84 pub trust_root: Option<crate::primitives::anchor::AnchorEntry>,
85}
86
87#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
89#[serde(rename_all = "kebab-case")]
90pub enum AuthorityType {
91 #[serde(rename = "x509-ca")]
93 X509Ca,
94 #[serde(rename = "did-method")]
96 DidMethod,
97 #[serde(rename = "starknet-registry")]
99 StarknetRegistry,
100 #[serde(rename = "ietf-ta")]
102 IetfTa,
103}
104
105#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
107#[serde(untagged)]
108pub enum AuthorityId {
109 Bytes(#[serde(with = "serde_bytes")] Vec<u8>),
111 Text(String),
113}
114
115#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
117pub struct DelegationScope {
118 #[serde(rename = "predicate-types", default, skip_serializing_if = "Option::is_none")]
120 pub predicate_types: Option<Vec<crate::primitives::predicate::PredicateType>>,
121 #[serde(default, skip_serializing_if = "Option::is_none")]
123 pub domains: Option<Vec<String>>,
124 #[serde(rename = "max-depth", default, skip_serializing_if = "Option::is_none")]
126 pub max_depth: Option<u64>,
127}
128
129#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
131pub struct AggregationBody {
132 pub count: u64,
134 #[serde(rename = "aggregated-evidence")]
136 pub aggregated_evidence: StarkProofEnvelope,
137 #[serde(rename = "issuer-bindings")]
139 pub issuer_bindings: Vec<IssuerBinding>,
140}
141
142#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
144pub struct IssuerBinding {
145 pub operand: ClaimRef,
147 #[serde(rename = "issuer-key", with = "serde_bytes")]
149 pub issuer_key: Vec<u8>,
150 #[serde(rename = "issuer-anchor", default, skip_serializing_if = "Option::is_none")]
152 pub issuer_anchor: Option<crate::primitives::anchor::AnchorEntry>,
153}
154
155#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
157pub struct RestrictionBody {
158 pub source: ClaimRef,
160 pub mask: RevelationMask,
162 #[serde(rename = "monotonicity-proof", default, skip_serializing_if = "Option::is_none")]
164 pub monotonicity_proof: Option<alloc::vec::Vec<u8>>,
165}
166
167#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
169pub struct RevocationBody {
170 pub source: ClaimRef,
172 #[serde(rename = "revoked-at")]
174 pub revoked_at: u64,
175 pub revoker: Authority,
177 pub proof: RevocationProof,
179 #[serde(rename = "reason-code", default, skip_serializing_if = "Option::is_none")]
181 pub reason_code: Option<RevocationReasonCode>,
182}
183
184#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
186#[serde(tag = "mode", rename_all = "kebab-case")]
187pub enum RevocationProof {
188 Nullifier {
190 #[serde(with = "serde_bytes")]
192 nullifier: Vec<u8>,
193 #[serde(rename = "anchor-entry")]
195 anchor_entry: crate::primitives::anchor::AnchorEntry,
196 },
197 StatusList {
199 #[serde(rename = "list-uri")]
201 list_uri: String,
202 #[serde(rename = "list-index")]
204 list_index: u64,
205 #[serde(default, skip_serializing_if = "Option::is_none", with = "opt_bytes")]
207 signature: Option<Vec<u8>>,
208 #[serde(rename = "batch-anchor", default, skip_serializing_if = "Option::is_none")]
210 batch_anchor: Option<crate::primitives::anchor::AnchorEntry>,
211 },
212}
213
214#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
216#[serde(rename_all = "kebab-case")]
217pub enum RevocationReasonCode {
218 Unspecified,
220 KeyCompromise,
222 CaCompromise,
224 AffiliationChanged,
226 Superseded,
228 CessationOfOperation,
230 CertificateHold,
232 PrivilegeWithdrawn,
234}
235
236mod opt_bytes {
237 use alloc::vec::Vec;
238 use serde::{Deserialize, Deserializer, Serialize, Serializer};
239 pub fn serialize<S: Serializer>(v: &Option<Vec<u8>>, s: S) -> Result<S::Ok, S::Error> {
240 match v {
241 Some(b) => serde_bytes::Bytes::new(b).serialize(s),
242 None => s.serialize_none(),
243 }
244 }
245 pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Option<Vec<u8>>, D::Error> {
246 let opt: Option<serde_bytes::ByteBuf> = Option::deserialize(d)?;
247 Ok(opt.map(serde_bytes::ByteBuf::into_vec))
248 }
249}
250
251#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
253pub struct CompositionRecord {
254 pub operator: OperatorTag,
256 pub operands: Vec<ClaimRef>,
258 pub depth: u64,
260 #[serde(default, skip_serializing_if = "Option::is_none")]
262 pub metadata: Option<BTreeMap<String, serde_json::Value>>,
263 #[serde(default, skip_serializing_if = "Option::is_none")]
265 pub body: Option<OperatorBody>,
266}
267
268pub trait ClaimComposition {
270 fn conjunct(&self, other: &Claim) -> Result<Claim, CompositionError>;
272
273 fn delegate(&self, authority: &Authority, scope: DelegationScope)
275 -> Result<Claim, CompositionError>;
276
277 fn aggregate(
280 claims: &[Claim],
281 bindings: Vec<IssuerBinding>,
282 aggregated_evidence: StarkProofEnvelope,
283 ) -> Result<Claim, CompositionError>;
284
285 fn restrict(&self, mask: &RevelationMask) -> Result<Claim, CompositionError>;
287
288 fn revoke(
290 &self,
291 revoker: Authority,
292 revoked_at: u64,
293 proof: RevocationProof,
294 reason: Option<RevocationReasonCode>,
295 ) -> Result<Claim, CompositionError>;
296}
297
298impl ClaimComposition for Claim {
299 fn conjunct(&self, other: &Claim) -> Result<Claim, CompositionError> {
300 if self.subject != other.subject {
302 return Err(CompositionError::SubjectMismatch);
303 }
304 let temporal = self
306 .temporal_frame
307 .intersect(&other.temporal_frame)
308 .ok_or(CompositionError::TemporalDisjoint)?;
309 let anchor = self.anchor.union(&other.anchor);
311 let mask = merge_masks(&self.revelation_mask, &other.revelation_mask)?;
313 let left = self.claim_ref().expect("claim_ref encoding");
314 let right = other.claim_ref().expect("claim_ref encoding");
315 let body = OperatorBody::Conjunction(ConjunctionBody {
316 left: left.clone(),
317 right: right.clone(),
318 linkage_proof: None,
319 });
320 let depth = 1 + self.depth().max(other.depth());
321 let record = CompositionRecord {
322 operator: OperatorTag::Conjunction,
323 operands: vec![left, right],
324 depth,
325 metadata: None,
326 body: Some(body),
327 };
328 let claim = Claim {
329 subject: self.subject.clone(),
330 predicate: self.predicate.clone(),
331 evidence: self.evidence.clone(),
332 temporal_frame: temporal,
333 revelation_mask: mask,
334 anchor,
335 composition: Some(record),
336 extensions: None,
337 #[cfg(feature = "transcript-v2")]
338 transcript_version: crate::transcript_v2::TranscriptVersion::default(),
339 };
340 claim.validate()?;
341 Ok(claim)
342 }
343
344 fn delegate(
345 &self,
346 authority: &Authority,
347 scope: DelegationScope,
348 ) -> Result<Claim, CompositionError> {
349 if let Some(parent_record) = &self.composition {
352 if matches!(parent_record.operator, OperatorTag::Delegation) {
353 if let Some(OperatorBody::Delegation(parent)) = &parent_record.body {
354 enforce_scope_subset(&parent.scope, &scope)?;
355 if let Some(max_depth) = parent.scope.max_depth {
356 if parent_record.depth >= max_depth {
357 return Err(CompositionError::ScopeOverflow);
358 }
359 }
360 }
361 }
362 }
363 let mut chain = BTreeSet::new();
365 let mut cursor = Some(self);
366 while let Some(c) = cursor {
367 let r = c.claim_ref().expect("claim_ref encoding");
368 if !chain.insert(r.digest.clone()) {
369 return Err(CompositionError::DelegationCycle);
370 }
371 cursor = None; if let Some(rec) = &c.composition {
374 if let Some(OperatorBody::Delegation(d)) = &rec.body {
375 if d.parent.digest == r.digest {
376 return Err(CompositionError::DelegationCycle);
377 }
378 }
379 }
380 }
381 let parent_ref = self.claim_ref().expect("claim_ref encoding");
382 let body = OperatorBody::Delegation(DelegationBody {
383 parent: parent_ref.clone(),
384 authority: authority.clone(),
385 scope,
386 });
387 let depth = 1 + self.depth();
388 let record = CompositionRecord {
389 operator: OperatorTag::Delegation,
390 operands: vec![parent_ref],
391 depth,
392 metadata: None,
393 body: Some(body),
394 };
395 Ok(Claim {
396 subject: self.subject.clone(),
397 predicate: self.predicate.clone(),
398 evidence: self.evidence.clone(),
399 temporal_frame: self.temporal_frame,
400 revelation_mask: self.revelation_mask.clone(),
401 anchor: self.anchor.clone(),
402 composition: Some(record),
403 extensions: None,
404 #[cfg(feature = "transcript-v2")]
405 transcript_version: crate::transcript_v2::TranscriptVersion::default(),
406 })
407 }
408
409 fn aggregate(
410 claims: &[Claim],
411 bindings: Vec<IssuerBinding>,
412 aggregated_evidence: StarkProofEnvelope,
413 ) -> Result<Claim, CompositionError> {
414 if claims.len() < 2 {
415 return Err(CompositionError::AggregationTooFew);
416 }
417 if bindings.len() != claims.len() {
418 return Err(CompositionError::Invariant(
419 "issuer_bindings.len must equal operands.len",
420 ));
421 }
422 let mut seen = BTreeSet::new();
424 for b in &bindings {
425 if !seen.insert(b.issuer_key.clone()) {
426 return Err(CompositionError::IssuerDuplicate);
427 }
428 }
429 let mut temporal = claims[0].temporal_frame;
431 let mut anchor = claims[0].anchor.clone();
432 for c in &claims[1..] {
433 temporal = temporal
434 .intersect(&c.temporal_frame)
435 .ok_or(CompositionError::TemporalDisjoint)?;
436 anchor = anchor.union(&c.anchor);
437 }
438 let operands: Vec<ClaimRef> = claims
439 .iter()
440 .map(|c| c.claim_ref().expect("claim_ref encoding"))
441 .collect();
442 let depth = 1 + claims.iter().map(Claim::depth).max().unwrap_or(0);
443 let body = OperatorBody::Aggregation(AggregationBody {
444 count: claims.len() as u64,
445 aggregated_evidence: aggregated_evidence.clone(),
446 issuer_bindings: bindings,
447 });
448 let evidence = Evidence::new(
449 EvidenceScheme::Stark,
450 aggregated_evidence.proof.clone(),
451 Some(EvidenceEnvelope::Stark(aggregated_evidence)),
452 )?;
453 Ok(Claim {
454 subject: claims[0].subject.clone(),
455 predicate: claims[0].predicate.clone(),
456 evidence,
457 temporal_frame: temporal,
458 revelation_mask: claims[0].revelation_mask.clone(),
459 anchor,
460 composition: Some(CompositionRecord {
461 operator: OperatorTag::Aggregation,
462 operands,
463 depth,
464 metadata: None,
465 body: Some(body),
466 }),
467 extensions: None,
468 #[cfg(feature = "transcript-v2")]
469 transcript_version: crate::transcript_v2::TranscriptVersion::default(),
470 })
471 }
472
473 fn restrict(&self, mask: &RevelationMask) -> Result<Claim, CompositionError> {
474 if !mask.refines(&self.revelation_mask) {
476 return Err(CompositionError::MaskNonMonotonic);
477 }
478 mask.validate_shape()?;
479 let source = self.claim_ref().expect("claim_ref encoding");
480 let body = OperatorBody::Restriction(RestrictionBody {
481 source: source.clone(),
482 mask: mask.clone(),
483 monotonicity_proof: None,
484 });
485 let depth = 1 + self.depth();
486 Ok(Claim {
487 subject: self.subject.clone(),
488 predicate: self.predicate.clone(),
489 evidence: self.evidence.clone(),
490 temporal_frame: self.temporal_frame,
491 revelation_mask: mask.clone(),
492 anchor: self.anchor.clone(),
493 composition: Some(CompositionRecord {
494 operator: OperatorTag::Restriction,
495 operands: vec![source],
496 depth,
497 metadata: None,
498 body: Some(body),
499 }),
500 extensions: None,
501 #[cfg(feature = "transcript-v2")]
502 transcript_version: crate::transcript_v2::TranscriptVersion::default(),
503 })
504 }
505
506 fn revoke(
507 &self,
508 revoker: Authority,
509 revoked_at: u64,
510 proof: RevocationProof,
511 reason: Option<RevocationReasonCode>,
512 ) -> Result<Claim, CompositionError> {
513 match &proof {
515 RevocationProof::Nullifier { nullifier, .. } => {
516 if nullifier.len() != 32 {
517 return Err(CompositionError::Invariant(
518 "V-2: nullifier must be 32 bytes (Poseidon-felt252)",
519 ));
520 }
521 }
522 RevocationProof::StatusList { list_uri, .. } => {
523 if list_uri.is_empty() {
524 return Err(CompositionError::Invariant(
525 "V-2: status-list revocation requires a non-empty list_uri",
526 ));
527 }
528 }
529 }
530 if self.temporal_frame.is_revoked_at(revoked_at) {
533 return Err(CompositionError::AlreadyRevoked);
534 }
535 let source = self.claim_ref().expect("claim_ref encoding");
536 let body = OperatorBody::Revocation(RevocationBody {
537 source: source.clone(),
538 revoked_at,
539 revoker,
540 proof,
541 reason_code: reason,
542 });
543 let mut frame = self.temporal_frame;
544 frame.revoked_at = Some(revoked_at);
545 let depth = 1 + self.depth();
546 Ok(Claim {
547 subject: self.subject.clone(),
548 predicate: self.predicate.clone(),
549 evidence: self.evidence.clone(),
550 temporal_frame: frame,
551 revelation_mask: self.revelation_mask.clone(),
552 anchor: self.anchor.clone(),
553 composition: Some(CompositionRecord {
554 operator: OperatorTag::Revocation,
555 operands: vec![source],
556 depth,
557 metadata: None,
558 body: Some(body),
559 }),
560 extensions: None,
561 #[cfg(feature = "transcript-v2")]
562 transcript_version: crate::transcript_v2::TranscriptVersion::default(),
563 })
564 }
565}
566
567fn merge_masks(
568 a: &RevelationMask,
569 b: &RevelationMask,
570) -> Result<RevelationMask, CompositionError> {
571 let mut disclosed = a.disclosed.clone();
572 for d in &b.disclosed {
573 if !disclosed.contains(d) {
574 disclosed.push(d.clone());
575 }
576 }
577 let mut committed = a.committed.clone();
578 for c in &b.committed {
579 if !committed.iter().any(|x| x.path == c.path) {
580 committed.push(c.clone());
581 }
582 }
583 RevelationMask::new(disclosed, committed, a.hash_alg.or(b.hash_alg))
584}
585
586fn enforce_scope_subset(
587 parent: &DelegationScope,
588 child: &DelegationScope,
589) -> Result<(), CompositionError> {
590 if let (Some(parent_pt), Some(child_pt)) = (&parent.predicate_types, &child.predicate_types) {
591 let p: BTreeSet<_> = parent_pt.iter().collect();
592 for x in child_pt {
593 if !p.contains(x) {
594 return Err(CompositionError::ScopeOverflow);
595 }
596 }
597 }
598 if let (Some(parent_dom), Some(child_dom)) = (&parent.domains, &child.domains) {
599 for x in child_dom {
600 if !parent_dom.iter().any(|p| x.starts_with(p)) {
601 return Err(CompositionError::ScopeOverflow);
602 }
603 }
604 }
605 if let (Some(parent_md), Some(child_md)) = (parent.max_depth, child.max_depth) {
606 if child_md > parent_md {
607 return Err(CompositionError::ScopeOverflow);
608 }
609 }
610 Ok(())
611}