vauban-claim 0.1.0

Vauban Claim Algebra — reference implementation of draft-vauban-claim-algebra-00 (post-quantum claim sextuplet + 5 composition operators, canonical CBOR/JSON codec).
Documentation
//! Top-level Claim sextuplet and content-addressed reference.

use alloc::collections::BTreeMap;
use alloc::string::String;
use alloc::vec::Vec;
use serde::{Deserialize, Serialize};
use sha2::Digest;

use crate::composition::CompositionRecord;
use crate::error::{CompositionError, EncodingError};
use crate::primitives::{
    anchor::Anchor, evidence::Evidence, predicate::Predicate,
    revelation_mask::RevelationMask, subject::Subject, temporal::TemporalFrame,
};
#[cfg(feature = "transcript-v2")]
use crate::transcript_v2::TranscriptVersion;

/// Digest algorithm for `ClaimRef` (CDDL `claim-ref-alg`).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum ClaimRefAlg {
    /// SHA-256 — default for non-ZK contexts.
    #[serde(rename = "sha-256")]
    Sha256,
    /// Poseidon-felt252 — ZK-friendly for STARK aggregation.
    #[serde(rename = "poseidon-felt252")]
    PoseidonFelt252,
}

impl Default for ClaimRefAlg {
    fn default() -> Self {
        Self::Sha256
    }
}

/// Content-addressed Claim reference (CDDL `claim-ref`).
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct ClaimRef {
    /// Digest bytes (length depends on `digest_alg`).
    #[serde(with = "serde_bytes")]
    pub digest: Vec<u8>,
    /// Optional discriminator; defaults to `sha-256`.
    #[serde(rename = "digest-alg", default, skip_serializing_if = "Option::is_none")]
    pub digest_alg: Option<ClaimRefAlg>,
}

impl ClaimRef {
    /// Construct from raw digest bytes (no algorithm tag — defaults to SHA-256).
    pub fn from_digest(digest: Vec<u8>) -> Self {
        Self {
            digest,
            digest_alg: None,
        }
    }
}

/// Top-level Claim (CDDL §5).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Claim {
    /// CDDL §5.1.
    pub subject: Subject,
    /// CDDL §5.2.
    pub predicate: Predicate,
    /// CDDL §5.3.
    pub evidence: Evidence,
    /// CDDL §5.4.
    #[serde(rename = "temporal-frame")]
    pub temporal_frame: TemporalFrame,
    /// CDDL §5.5.
    #[serde(rename = "revelation-mask")]
    pub revelation_mask: RevelationMask,
    /// CDDL §5.6.
    pub anchor: Anchor,
    /// Optional composition record (root of a composition DAG).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub composition: Option<CompositionRecord>,
    /// Forward-compatible extensions bag.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub extensions: Option<BTreeMap<String, serde_json::Value>>,
    /// Transcript version for dual-transcript period (§6 migration plan).
    ///
    /// Defaults to `V1` for backward compatibility. Claims targeting PQ security
    /// must set this to `V2` (TranscriptT2, Poseidon2-felt252 sponge).
    /// Only available with `feature = "transcript-v2"`.
    #[cfg(feature = "transcript-v2")]
    #[serde(
        rename = "transcript-version",
        default,
        skip_serializing_if = "is_default_transcript_version"
    )]
    pub transcript_version: TranscriptVersion,
}

/// Helper for `skip_serializing_if` — returns `true` when `transcript_version` is
/// the default `V1` (omitted from serialised form for backward compat).
#[cfg(feature = "transcript-v2")]
#[allow(clippy::trivially_copy_pass_by_ref)]
fn is_default_transcript_version(v: &TranscriptVersion) -> bool {
    *v == TranscriptVersion::V1
}

/// Dispatch result from [`Claim::verify_with_transcript`].
///
/// Wraps either a V1 (Merlin) or V2 (TranscriptT2) transcript, ready for
/// challenge extraction by the caller.
#[cfg(feature = "transcript-v2")]
pub enum TranscriptResult {
    /// Merlin transcript (TranscriptT1, Strobe-128).
    V1(merlin::Transcript),
    /// Poseidon2-felt252 sponge transcript (TranscriptT2, PQ-sound).
    V2(crate::transcript_v2::TranscriptT2),
}

#[cfg(feature = "transcript-v2")]
impl core::fmt::Debug for TranscriptResult {
    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
        match self {
            Self::V1(_) => write!(f, "TranscriptResult::V1(<Merlin>)"),
            Self::V2(_) => write!(f, "TranscriptResult::V2(<TranscriptT2>)"),
        }
    }
}

impl Claim {
    /// Validate all primitive shapes + `composition.depth` consistency.
    pub fn validate(&self) -> Result<(), CompositionError> {
        self.subject.validate_shape()?;
        self.predicate.validate_shape()?;
        self.evidence.validate_shape()?;
        self.temporal_frame.validate_shape()?;
        self.revelation_mask.validate_shape()?;
        for e in &self.anchor.0 {
            e.validate_shape()?;
        }
        if self.anchor.0.is_empty() {
            return Err(CompositionError::Invariant(
                "anchor list must be non-empty (I-4)",
            ));
        }
        Ok(())
    }

    /// Compute content-addressed `ClaimRef` (SHA-256 over canonical CBOR).
    pub fn claim_ref(&self) -> Result<ClaimRef, EncodingError> {
        let bytes = crate::codec::to_cbor_canonical(self)?;
        let digest = sha2::Sha256::digest(&bytes);
        Ok(ClaimRef {
            digest: digest.to_vec(),
            digest_alg: Some(ClaimRefAlg::Sha256),
        })
    }

    /// Compute content-addressed `ClaimRef` with explicit digest algorithm.
    ///
    /// When the `poseidon` feature is enabled, `ClaimRefAlg::PoseidonFelt252`
    /// produces a ZK-friendly digest over the Starknet field. Otherwise it
    /// falls back to SHA-256.
    pub fn claim_ref_with(&self, alg: ClaimRefAlg) -> Result<ClaimRef, EncodingError> {
        let bytes = crate::codec::to_cbor_canonical(self)?;
        let digest = match alg {
            ClaimRefAlg::Sha256 => sha2::Sha256::digest(&bytes).to_vec(),
            ClaimRefAlg::PoseidonFelt252 => {
                #[cfg(feature = "poseidon")]
                {
                    crate::poseidon::poseidon_felt252(&bytes).to_vec()
                }
                #[cfg(not(feature = "poseidon"))]
                {
                    sha2::Sha256::digest(&bytes).to_vec()
                }
            }
        };
        Ok(ClaimRef {
            digest,
            digest_alg: Some(alg),
        })
    }

    /// Whether this Claim is atomic (no composition record, depth = 0).
    pub fn is_atomic(&self) -> bool {
        self.composition.is_none()
    }

    /// Composition depth — `0` for atomic Claims, otherwise from the record.
    pub fn depth(&self) -> u64 {
        self.composition.as_ref().map_or(0, |c| c.depth)
    }

    /// Bind the Claim into the appropriate transcript based on `transcript_version`.
    ///
    /// Routes to [`crate::transcript::bind_claim`] for V1 (Merlin/Strobe) or
    /// [`crate::transcript_v2::bind_claim_v2`] for V2 (Poseidon2-felt252).
    ///
    /// Returns [`crate::error::TranscriptError::VersionMismatch`] if the Claim's
    /// `transcript_version` does not match `required_version` (downgrade rejection,
    /// §6 migration plan, Q5 mitigation).
    ///
    /// See `docs/research/06-orq-pq-transcript.md` §6 for the migration plan.
    #[cfg(feature = "transcript-v2")]
    pub fn verify_with_transcript(
        &self,
        verifier_ctx: &[u8],
        nonce: &[u8],
        required_version: TranscriptVersion,
    ) -> Result<TranscriptResult, crate::error::TranscriptError> {
        if self.transcript_version != required_version {
            return Err(crate::error::TranscriptError::VersionMismatch {
                claim: alloc::format!("{:?}", self.transcript_version),
                expected: alloc::format!("{required_version:?}"),
            });
        }
        match required_version {
            TranscriptVersion::V1 => {
                let t = crate::transcript::bind_claim(self, verifier_ctx, nonce)?;
                Ok(TranscriptResult::V1(t))
            }
            TranscriptVersion::V2 => {
                let t = crate::transcript_v2::bind_claim_v2(self, verifier_ctx, nonce)?;
                Ok(TranscriptResult::V2(t))
            }
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::builder::ClaimBuilder;
    use crate::primitives::anchor::{Anchor, AnchorEntry, AnchorType};
    use crate::primitives::evidence::{Evidence, EvidenceScheme};
    use crate::primitives::predicate::{Predicate, PredicateType};
    use crate::primitives::revelation_mask::RevelationMask;
    use crate::primitives::subject::{Subject, SubjectId, SubjectType};
    use crate::primitives::temporal::TemporalFrame;

    fn fixture_claim() -> Claim {
        ClaimBuilder::default()
            .subject(Subject {
                subject_type: SubjectType::Wallet,
                id: SubjectId::Bytes(hex::decode(
                    "bbbe91b88ff2842d7f7af15cd8154cdcc753dc3997e46be3568e7ef1ab5e90f4",
                )
                .unwrap()),
                binding: None,
            })
            .predicate(
                Predicate::new(
                    PredicateType::Equality,
                    "vauban.claim",
                    b"body".to_vec(),
                )
                .unwrap(),
            )
            .evidence(
                Evidence::new(EvidenceScheme::Ed25519, vec![0xcd; 64], None).unwrap(),
            )
            .temporal_frame(
                TemporalFrame::new(1_700_000_000, None, None).unwrap(),
            )
            .revelation_mask(
                RevelationMask::new(vec!["*".into()], vec![], None).unwrap(),
            )
            .anchor(
                Anchor::new(vec![AnchorEntry {
                    anchor_type: AnchorType::StarknetL3,
                    r#ref: vec![0xab; 32],
                    epoch: Some(42),
                    nullifier: None,
                    meta: None,
                }])
                .unwrap(),
            )
            .build()
            .expect("fixture claim")
    }

    #[test]
    fn claim_ref_sha256_determinism() {
        let c = fixture_claim();
        let r1 = c.claim_ref().unwrap();
        let r2 = c.claim_ref().unwrap();
        assert_eq!(r1.digest, r2.digest);
        assert_eq!(r1.digest.len(), 32); // SHA-256 = 32 bytes
        assert_eq!(r1.digest_alg, Some(ClaimRefAlg::Sha256));
    }

    #[test]
    fn claim_ref_sha256_explicit() {
        let c = fixture_claim();
        let r1 = c.claim_ref().unwrap();
        let r2 = c.claim_ref_with(ClaimRefAlg::Sha256).unwrap();
        assert_eq!(r1.digest, r2.digest);
    }

    #[test]
    fn claim_ref_different_inputs_different_digests() {
        let c1 = fixture_claim();

        let mut c2 = fixture_claim();
        c2.predicate = Predicate::new(
            PredicateType::Equality,
            "different.domain",
            b"different".to_vec(),
        )
        .unwrap();

        let r1 = c1.claim_ref().unwrap();
        let r2 = c2.claim_ref().unwrap();
        assert_ne!(r1.digest, r2.digest);
    }

    #[test]
    fn claim_ref_poseidon_determinism() {
        let c = fixture_claim();
        let r1 = c.claim_ref_with(ClaimRefAlg::PoseidonFelt252).unwrap();
        let r2 = c.claim_ref_with(ClaimRefAlg::PoseidonFelt252).unwrap();
        assert_eq!(r1.digest, r2.digest);
        assert_eq!(r1.digest.len(), 32); // Poseidon felt252 → 32 bytes
    }

    #[test]
    fn claim_ref_poseidon_vs_sha256_independent() {
        let c = fixture_claim();
        let r_sha = c.claim_ref().unwrap();
        let r_poseidon = c.claim_ref_with(ClaimRefAlg::PoseidonFelt252).unwrap();
        assert_eq!(r_sha.digest_alg, Some(ClaimRefAlg::Sha256));
        #[cfg(feature = "poseidon")]
        {
            // With poseidon enabled, algorithms must produce different digests
            assert_ne!(r_sha.digest, r_poseidon.digest);
            assert_eq!(r_poseidon.digest.len(), 32);
        }
        #[cfg(not(feature = "poseidon"))]
        {
            // Fallback to SHA-256 when poseidon feature is disabled
            assert_eq!(r_poseidon.digest, r_sha.digest);
        }
    }
}