Skip to main content

vauban_claim/
composition.rs

1//! Five composition operators (CDDL §6) and the `composition-record` envelope.
2
3use 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/// Operator discriminator (CDDL `operator-tag`).
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
18#[serde(rename_all = "kebab-case")]
19pub enum OperatorTag {
20    /// `∧` — logical AND.
21    Conjunction,
22    /// `→` — authority chain.
23    Delegation,
24    /// `⊕` — multi-issuer aggregation in one STARK.
25    Aggregation,
26    /// `▷` — narrowing of revelation mask.
27    Restriction,
28    /// `¬` — sticky revocation.
29    Revocation,
30}
31
32/// Operator-specific record body.
33#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
34#[serde(untagged)]
35pub enum OperatorBody {
36    /// §6.1 conjunction body.
37    Conjunction(ConjunctionBody),
38    /// §6.2 delegation body.
39    Delegation(DelegationBody),
40    /// §6.3 aggregation body.
41    Aggregation(AggregationBody),
42    /// §6.4 restriction body.
43    Restriction(RestrictionBody),
44    /// §6.5 revocation body.
45    Revocation(RevocationBody),
46}
47
48/// CDDL §6.1 conjunction body.
49#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
50pub struct ConjunctionBody {
51    /// Left operand.
52    pub left: ClaimRef,
53    /// Right operand.
54    pub right: ClaimRef,
55    /// Linkage proof — required iff subjects differ (C-1).
56    #[serde(rename = "linkage-proof", default, skip_serializing_if = "Option::is_none")]
57    pub linkage_proof: Option<alloc::vec::Vec<u8>>,
58}
59
60/// CDDL §6.2 delegation body — one chain link per composition node.
61#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
62pub struct DelegationBody {
63    /// Reference to the previous link in the chain.
64    pub parent: ClaimRef,
65    /// Authority making this delegation step.
66    pub authority: Authority,
67    /// Constrained scope the delegate may issue.
68    pub scope: DelegationScope,
69}
70
71/// Authority record (CDDL `authority`).
72#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
73pub struct Authority {
74    /// Discriminator.
75    #[serde(rename = "type")]
76    pub authority_type: AuthorityType,
77    /// Identifier — bytes (e.g. CSCA name) or text (DID).
78    pub identifier: AuthorityId,
79    /// Optional public-key reference.
80    #[serde(rename = "key-ref", default, skip_serializing_if = "Option::is_none", with = "opt_bytes")]
81    pub key_ref: Option<Vec<u8>>,
82    /// Optional anchor pinning the authority to a trust root.
83    #[serde(rename = "trust-root", default, skip_serializing_if = "Option::is_none")]
84    pub trust_root: Option<crate::primitives::anchor::AnchorEntry>,
85}
86
87/// Authority discriminator.
88#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
89#[serde(rename_all = "kebab-case")]
90pub enum AuthorityType {
91    /// PKI X.509 certificate authority.
92    #[serde(rename = "x509-ca")]
93    X509Ca,
94    /// W3C DID-method authority.
95    #[serde(rename = "did-method")]
96    DidMethod,
97    /// Cairo on-chain registry contract.
98    #[serde(rename = "starknet-registry")]
99    StarknetRegistry,
100    /// IETF Trust Anchor (RFC 5914).
101    #[serde(rename = "ietf-ta")]
102    IetfTa,
103}
104
105/// Authority identifier — bytes or text.
106#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
107#[serde(untagged)]
108pub enum AuthorityId {
109    /// Byte-encoded identifier.
110    Bytes(#[serde(with = "serde_bytes")] Vec<u8>),
111    /// Textual identifier.
112    Text(String),
113}
114
115/// Delegation scope (CDDL `delegation-scope`).
116#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
117pub struct DelegationScope {
118    /// Allowed predicate types — `None` ⇒ inherit parent.
119    #[serde(rename = "predicate-types", default, skip_serializing_if = "Option::is_none")]
120    pub predicate_types: Option<Vec<crate::primitives::predicate::PredicateType>>,
121    /// Allowed `predicate.domain` prefixes.
122    #[serde(default, skip_serializing_if = "Option::is_none")]
123    pub domains: Option<Vec<String>>,
124    /// Path-length constraint (RFC 5280 §4.2.1.9).
125    #[serde(rename = "max-depth", default, skip_serializing_if = "Option::is_none")]
126    pub max_depth: Option<u64>,
127}
128
129/// CDDL §6.3 aggregation body.
130#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
131pub struct AggregationBody {
132    /// Number of operands (must equal `composition_record.operands.len()`).
133    pub count: u64,
134    /// Single STARK proof verifying every operand jointly.
135    #[serde(rename = "aggregated-evidence")]
136    pub aggregated_evidence: StarkProofEnvelope,
137    /// Per-operand issuer key bindings (machine-checkable G-1).
138    #[serde(rename = "issuer-bindings")]
139    pub issuer_bindings: Vec<IssuerBinding>,
140}
141
142/// Issuer binding for aggregation diversity check (CDDL `issuer-binding`).
143#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
144pub struct IssuerBinding {
145    /// Operand reference.
146    pub operand: ClaimRef,
147    /// Canonical pubkey of the operand's issuer (e.g. JWK thumbprint).
148    #[serde(rename = "issuer-key", with = "serde_bytes")]
149    pub issuer_key: Vec<u8>,
150    /// Optional anchor pinning the issuer.
151    #[serde(rename = "issuer-anchor", default, skip_serializing_if = "Option::is_none")]
152    pub issuer_anchor: Option<crate::primitives::anchor::AnchorEntry>,
153}
154
155/// CDDL §6.4 restriction body.
156#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
157pub struct RestrictionBody {
158    /// Source Claim being restricted.
159    pub source: ClaimRef,
160    /// New (narrower) mask.
161    pub mask: RevelationMask,
162    /// Optional proof of monotonicity.
163    #[serde(rename = "monotonicity-proof", default, skip_serializing_if = "Option::is_none")]
164    pub monotonicity_proof: Option<alloc::vec::Vec<u8>>,
165}
166
167/// CDDL §6.5 revocation body.
168#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
169pub struct RevocationBody {
170    /// Source Claim being revoked.
171    pub source: ClaimRef,
172    /// POSIX-epoch revocation time.
173    #[serde(rename = "revoked-at")]
174    pub revoked_at: u64,
175    /// Authority issuing the revocation.
176    pub revoker: Authority,
177    /// Mode-specific revocation evidence.
178    pub proof: RevocationProof,
179    /// Optional RFC 5280 §5.3.1 reason code.
180    #[serde(rename = "reason-code", default, skip_serializing_if = "Option::is_none")]
181    pub reason_code: Option<RevocationReasonCode>,
182}
183
184/// Revocation evidence variants (CDDL `revocation-proof`).
185#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
186#[serde(tag = "mode", rename_all = "kebab-case")]
187pub enum RevocationProof {
188    /// On-chain Poseidon nullifier inclusion.
189    Nullifier {
190        /// 32-byte Poseidon-felt252 nullifier.
191        #[serde(with = "serde_bytes")]
192        nullifier: Vec<u8>,
193        /// On-chain inclusion anchor entry.
194        #[serde(rename = "anchor-entry")]
195        anchor_entry: crate::primitives::anchor::AnchorEntry,
196    },
197    /// Signed revocation status list.
198    StatusList {
199        /// URI of the published status list.
200        #[serde(rename = "list-uri")]
201        list_uri: String,
202        /// Index of this Claim in the list.
203        #[serde(rename = "list-index")]
204        list_index: u64,
205        /// Revoker signature over the list.
206        #[serde(default, skip_serializing_if = "Option::is_none", with = "opt_bytes")]
207        signature: Option<Vec<u8>>,
208        /// Optional batched on-chain commitment.
209        #[serde(rename = "batch-anchor", default, skip_serializing_if = "Option::is_none")]
210        batch_anchor: Option<crate::primitives::anchor::AnchorEntry>,
211    },
212}
213
214/// RFC 5280 §5.3.1 reason codes (subset relevant to Vauban Claims).
215#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
216#[serde(rename_all = "kebab-case")]
217pub enum RevocationReasonCode {
218    /// No specific reason given.
219    Unspecified,
220    /// Subject's private key has been compromised.
221    KeyCompromise,
222    /// CA's private key has been compromised.
223    CaCompromise,
224    /// Subject affiliation changed.
225    AffiliationChanged,
226    /// Replaced by another credential.
227    Superseded,
228    /// Issuing CA / authority no longer operates.
229    CessationOfOperation,
230    /// Temporary hold.
231    CertificateHold,
232    /// Subject no longer privileged to hold the credential.
233    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/// Composition record (CDDL `composition-record`).
252#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
253pub struct CompositionRecord {
254    /// Operator discriminator.
255    pub operator: OperatorTag,
256    /// Operand references.
257    pub operands: Vec<ClaimRef>,
258    /// Longest path from this node to any atomic leaf.
259    pub depth: u64,
260    /// Opaque metadata bag.
261    #[serde(default, skip_serializing_if = "Option::is_none")]
262    pub metadata: Option<BTreeMap<String, serde_json::Value>>,
263    /// Discriminated body.
264    #[serde(default, skip_serializing_if = "Option::is_none")]
265    pub body: Option<OperatorBody>,
266}
267
268/// Trait exposing the five operators on Claims.
269pub trait ClaimComposition {
270    /// `∧` — Conjunction (§6.1).
271    fn conjunct(&self, other: &Claim) -> Result<Claim, CompositionError>;
272
273    /// `→` — Delegation (§6.2). The `authority` issues this delegation step.
274    fn delegate(&self, authority: &Authority, scope: DelegationScope)
275        -> Result<Claim, CompositionError>;
276
277    /// `⊕` — Aggregation (§6.3). Single STARK across `claims`. Issuer keys
278    /// must be pairwise distinct (G-1).
279    fn aggregate(
280        claims: &[Claim],
281        bindings: Vec<IssuerBinding>,
282        aggregated_evidence: StarkProofEnvelope,
283    ) -> Result<Claim, CompositionError>;
284
285    /// `▷` — Restriction (§6.4). New mask must refine the source mask (R-1).
286    fn restrict(&self, mask: &RevelationMask) -> Result<Claim, CompositionError>;
287
288    /// `¬` — Revocation (§6.5). Returns a derived `Claim_revoked`.
289    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        // C-1 — subjects must match (or linkage proof would be required).
301        if self.subject != other.subject {
302            return Err(CompositionError::SubjectMismatch);
303        }
304        // C-3 — temporal-intersect.
305        let temporal = self
306            .temporal_frame
307            .intersect(&other.temporal_frame)
308            .ok_or(CompositionError::TemporalDisjoint)?;
309        // C-2 — anchor union.
310        let anchor = self.anchor.union(&other.anchor);
311        // C-4 — disclosed bound (use intersection of disclosed paths).
312        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        // D-3: each link must be valid at evaluation time — surfaced via temporal_frame.
350        // D-2: scope ⊆ parent scope. We compare against the immediate parent.
351        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        // Cycle detection by walking the parent chain via in-memory `composition`.
364        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; // in-memory chain stops; deeper detection would walk a Claim store.
372            // Local DAG: a self-loop happens only if the parent_ref of `self` references its own digest.
373            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        // G-1 — issuer-key diversity.
423        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        // Anchor union, temporal intersection (G-3).
430        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        // R-1 — monotonicity.
475        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        // V-2 — proof shape sanity.
514        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        // Sticky check (sub-rule, not a CDDL "MUST"): an already-revoked Claim
531        // should not be wrapped twice — surface to the caller for explicit handling.
532        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}