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
//! TranscriptT1 — Fiat-Shamir transcript hardening (F-THREAT-1 mitigation).
//!
//! Conforms to `specs/security/01-transcript-t1-spec.md`.
//! Binds every public input of a Claim into a Merlin transcript before
//! challenge derivation, preventing malleability attacks (IACR 2023/691,
//! 2025/118).

use merlin::Transcript;

use crate::claim::Claim;
use crate::codec::to_cbor_canonical;
use crate::error::TranscriptError;

/// Domain separator tag per §2.1 of the TranscriptT1 spec.
const DOMAIN_TAG: &[u8] = b"vauban-claim-v1-transcript-t1";

/// Absence sentinel (§2.2) — explicit length-0 prefix for optional fields.
static SENTINEL: &[u8] = &[];

mod labels {
    pub const DOMAIN: &[u8] = b"vauban-claim-v1";
    pub const SUBJECT_TYPE: &[u8] = b"subject.type";
    pub const SUBJECT_ID: &[u8] = b"subject.id";
    pub const SUBJECT_BINDING: &[u8] = b"subject.binding";
    pub const PREDICATE_TYPE: &[u8] = b"predicate.type";
    pub const PREDICATE_DOMAIN: &[u8] = b"predicate.domain";
    pub const PREDICATE_BODY: &[u8] = b"predicate.body";
    pub const EVIDENCE_SCHEME: &[u8] = b"evidence.scheme";
    pub const EVIDENCE_PROOF: &[u8] = b"evidence.proof";
    pub const EVIDENCE_PUBLIC_INPUTS: &[u8] = b"evidence.public-inputs";
    pub const EVIDENCE_VERIFIER_KEY: &[u8] = b"evidence.verifier-key";
    pub const TEMPORAL_NOT_BEFORE: &[u8] = b"temporal.not-before";
    pub const TEMPORAL_NOT_AFTER: &[u8] = b"temporal.not-after";
    pub const REVELATION_MASK: &[u8] = b"revelation-mask";
    pub const ANCHOR_URI: &[u8] = b"anchor.uri";
    pub const ANCHOR_HASH: &[u8] = b"anchor.hash";
    pub const ANCHOR_EPOCH: &[u8] = b"anchor.epoch";
    pub const VERIFIER_CTX: &[u8] = b"verifier-ctx";
    pub const PRESENTATION_NONCE: &[u8] = b"presentation-nonce";
}

/// Build a TranscriptT1-conformant transcript from a Claim and verifier context.
///
/// Returns the transcript **before** challenge derivation — the caller
/// (Verifier or test harness) extracts the challenge via
/// `transcript.challenge_bytes(label, dest)`.
///
/// # Security
///
/// This function MUST absorb the complete sextuplet in canonical order
/// before any challenge derivation. The domain separator tag MUST be the
/// first item absorbed.
#[allow(clippy::many_single_char_names)]
pub fn bind_claim(
    claim: &Claim,
    verifier_ctx: &[u8],
    nonce: &[u8],
) -> Result<Transcript, TranscriptError> {
    let mut t = Transcript::new(DOMAIN_TAG);

    // §2.1 Domain separation
    t.append_message(labels::DOMAIN, DOMAIN_TAG);

    // §2.2 Subject commit
    let s = &claim.subject;
    t.append_message(labels::SUBJECT_TYPE, s.subject_type_str().as_bytes());
    let id_bytes = match s.id_bytes() {
        Some(b) => b,
        None => s
            .id_utf8()
            .ok_or(TranscriptError::Missing("subject.id"))?
            .as_bytes(),
    };
    t.append_message(labels::SUBJECT_ID, id_bytes);
    match s.binding_bytes() {
        Some(b) => t.append_message(labels::SUBJECT_BINDING, b),
        None => t.append_message(labels::SUBJECT_BINDING, SENTINEL),
    }

    // §2.3 Predicate commit
    let p = &claim.predicate;
    t.append_message(labels::PREDICATE_TYPE, p.predicate_type_str().as_bytes());
    t.append_message(labels::PREDICATE_DOMAIN, p.domain().as_bytes());
    t.append_message(labels::PREDICATE_BODY, p.body());

    // §2.4 Evidence commit
    let e = &claim.evidence;
    t.append_message(labels::EVIDENCE_SCHEME, e.scheme_tag().as_bytes());
    if let Some(inputs) = e.public_inputs() {
        for inp in inputs {
            t.append_message(labels::EVIDENCE_PUBLIC_INPUTS, &inp.0);
        }
    } else {
        t.append_message(labels::EVIDENCE_PUBLIC_INPUTS, SENTINEL);
    }
    if let Some(vk) = e.verifier_key() {
        t.append_message(labels::EVIDENCE_VERIFIER_KEY, &vk.0);
    } else {
        t.append_message(labels::EVIDENCE_VERIFIER_KEY, SENTINEL);
    }
    t.append_message(labels::EVIDENCE_PROOF, e.proof());

    // §2.5 Temporal commit
    let tmp = &claim.temporal_frame;
    let nb = tmp.not_before();
    t.append_message(labels::TEMPORAL_NOT_BEFORE, &nb.to_be_bytes());
    match tmp.not_after() {
        Some(na) => t.append_message(labels::TEMPORAL_NOT_AFTER, &na.to_be_bytes()),
        None => t.append_message(labels::TEMPORAL_NOT_AFTER, SENTINEL),
    }

    // §2.6 Revelation mask commit
    let mask_cbor = to_cbor_canonical(&claim.revelation_mask)
        .map_err(|e| TranscriptError::Encoding(e.to_string()))?;
    t.append_message(labels::REVELATION_MASK, &mask_cbor);

    // §2.7 Anchor commit
    let a = &claim.anchor;
    t.append_message(labels::ANCHOR_URI, a.uri().as_bytes());
    t.append_message(labels::ANCHOR_HASH, a.hash());
    t.append_message(labels::ANCHOR_EPOCH, &a.epoch().to_be_bytes());

    // §2.8 Verifier context + presentation nonce
    t.append_message(labels::VERIFIER_CTX, verifier_ctx);
    t.append_message(labels::PRESENTATION_NONCE, nonce);

    Ok(t)
}

/// Extract a 32-byte challenge from the transcript (SHA-256 based).
pub fn extract_challenge(transcript: &mut Transcript) -> [u8; 32] {
    let mut challenge = [0u8; 32];
    transcript.challenge_bytes(b"vauban-challenge", &mut challenge);
    challenge
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::builder::ClaimBuilder;
    use crate::primitives::predicate::Predicate;
    use crate::primitives::subject::Subject;

    /// TV-T1-001: Two identical claims with same verifier ctx + nonce
    /// MUST produce the same transcript challenge.
    #[test]
    fn transcript_determinism() {
        let claim = minimal_claim();
        let mut t1 = bind_claim(&claim, b"verifier-1", b"nonce-1").unwrap();
        let mut t2 = bind_claim(&claim, b"verifier-1", b"nonce-1").unwrap();

        let c1 = extract_challenge(&mut t1);
        let c2 = extract_challenge(&mut t2);
        assert_eq!(c1, c2, "Determinism: identical inputs must yield identical challenges");
    }

    /// TV-T1-002: Different nonces MUST produce different challenges.
    #[test]
    fn transcript_nonce_binding() {
        let claim = minimal_claim();
        let mut t1 = bind_claim(&claim, b"verifier-1", b"nonce-1").unwrap();
        let mut t2 = bind_claim(&claim, b"verifier-1", b"nonce-2").unwrap();

        let c1 = extract_challenge(&mut t1);
        let c2 = extract_challenge(&mut t2);
        assert_ne!(c1, c2, "Nonce binding: different nonces must yield different challenges");
    }

    /// TV-T1-003: Different verifier ctx MUST produce different challenges.
    #[test]
    fn transcript_verifier_ctx_binding() {
        let claim = minimal_claim();
        let mut t1 = bind_claim(&claim, b"verifier-a", b"nonce-1").unwrap();
        let mut t2 = bind_claim(&claim, b"verifier-b", b"nonce-1").unwrap();

        let c1 = extract_challenge(&mut t1);
        let c2 = extract_challenge(&mut t2);
        assert_ne!(c1, c2, "Verifier-ctx binding: different ctx must yield different challenges");
    }

    /// TV-T1-004: Domain tag mismatch MUST be detectable.
    #[test]
    fn domain_tag_isolation() {
        let mut t1 = Transcript::new(DOMAIN_TAG);
        t1.append_message(labels::DOMAIN, DOMAIN_TAG);
        t1.append_message(b"data", b"test");

        let mut t2 = Transcript::new(b"wrong-domain-tag");
        t2.append_message(labels::DOMAIN, b"wrong-domain-tag");
        t2.append_message(b"data", b"test");

        let mut c1 = [0u8; 32];
        let mut c2 = [0u8; 32];
        t1.challenge_bytes(b"vauban-challenge", &mut c1);
        t2.challenge_bytes(b"vauban-challenge", &mut c2);
        assert_ne!(c1, c2, "Domain isolation: different tags must yield different challenges");
    }

    /// TV-T1-005: Absent subject.binding MUST use sentinel, not silent omission.
    #[test]
    fn absent_binding_sentinel() {
        let claim = minimal_claim();
        let mut t1 = bind_claim(&claim, b"v", b"n").unwrap();
        let c_with_sentinel = extract_challenge(&mut t1);

        // Manually construct transcript WITHOUT the sentinel (simulating silent omission)
        let mut t2 = Transcript::new(DOMAIN_TAG);
        t2.append_message(labels::DOMAIN, DOMAIN_TAG);
        let s = &claim.subject;
        t2.append_message(labels::SUBJECT_TYPE, s.subject_type_str().as_bytes());
        let id_bytes = s.id_bytes().unwrap_or_else(|| s.id_utf8().unwrap().as_bytes());
        t2.append_message(labels::SUBJECT_ID, id_bytes);
        // deliberately skip SUBJECT_BINDING
        let p = &claim.predicate;
        t2.append_message(labels::PREDICATE_TYPE, p.predicate_type_str().as_bytes());
        t2.append_message(labels::PREDICATE_DOMAIN, p.domain().as_bytes());
        t2.append_message(labels::PREDICATE_BODY, p.body());
        let e = &claim.evidence;
        t2.append_message(labels::EVIDENCE_SCHEME, e.scheme_tag().as_bytes());
        t2.append_message(labels::EVIDENCE_PROOF, e.proof());
        t2.append_message(labels::EVIDENCE_PUBLIC_INPUTS, SENTINEL);
        t2.append_message(labels::EVIDENCE_VERIFIER_KEY, SENTINEL);
        let tmp = &claim.temporal_frame;
        let nb = tmp.not_before();
        t2.append_message(labels::TEMPORAL_NOT_BEFORE, &nb.to_be_bytes());
        t2.append_message(labels::TEMPORAL_NOT_AFTER, SENTINEL);
        let mask_cbor = to_cbor_canonical(&claim.revelation_mask).unwrap();
        t2.append_message(labels::REVELATION_MASK, &mask_cbor);
        let a = &claim.anchor;
        t2.append_message(labels::ANCHOR_URI, a.uri().as_bytes());
        t2.append_message(labels::ANCHOR_HASH, a.hash());
        t2.append_message(labels::ANCHOR_EPOCH, &a.epoch().to_be_bytes());
        t2.append_message(labels::VERIFIER_CTX, b"v");
        t2.append_message(labels::PRESENTATION_NONCE, b"n");

        let mut c_without_sentinel = [0u8; 32];
        t2.challenge_bytes(b"vauban-challenge", &mut c_without_sentinel);
        assert_ne!(
            c_with_sentinel, c_without_sentinel,
            "Sentinel: silent omission vs explicit absent sentinel MUST differ"
        );
    }

    /// TV-T1-006: Wrong domain tag MUST produce different challenge.
    #[test]
    fn wrong_domain_rejected() {
        let mut t = Transcript::new(b"attacker-crafted-domain");
        t.append_message(labels::DOMAIN, b"attacker-crafted-domain");
        t.append_message(b"data", b"payload");

        let mut t_ref = Transcript::new(DOMAIN_TAG);
        t_ref.append_message(labels::DOMAIN, DOMAIN_TAG);
        t_ref.append_message(b"data", b"payload");

        let mut c = [0u8; 32];
        let mut c_ref = [0u8; 32];
        t.challenge_bytes(b"vauban-challenge", &mut c);
        t_ref.challenge_bytes(b"vauban-challenge", &mut c_ref);
        assert_ne!(c, c_ref);
    }

    fn minimal_claim() -> Claim {
        ClaimBuilder::default()
            .subject(Subject::wallet(
                hex::decode("bbbe91b88ff2842d7f7af15cd8154cdcc753dc3997e46be3568e7ef1ab5e90f4").unwrap(),
            ))
            .predicate(Predicate::new(
                crate::primitives::predicate::PredicateType::Equality,
                "vauban.claim",
                b"body".to_vec(),
            ).unwrap())
            .evidence(fixture_evidence_stark())
            .temporal_frame(
                crate::primitives::temporal::TemporalFrame::new(1_700_000_000, None, None).unwrap(),
            )
            .revelation_mask(
                crate::primitives::revelation_mask::RevelationMask::new(
                    vec!["*".into()],
                    vec![],
                    None,
                ).unwrap(),
            )
            .anchor(
                crate::primitives::anchor::Anchor::new(vec![
                    crate::primitives::anchor::AnchorEntry {
                        anchor_type: crate::primitives::anchor::AnchorType::StarknetL3,
                        r#ref: vec![0xab; 32],
                        epoch: Some(42),
                        nullifier: None,
                        meta: None,
                    },
                ]).unwrap(),
            )
            .build()
            .expect("minimal claim")
    }

    fn fixture_evidence_stark() -> crate::primitives::evidence::Evidence {
        use crate::primitives::evidence::{Evidence, EvidenceScheme, StarkProofEnvelope, ByteString};
        Evidence::new(
            EvidenceScheme::Stark,
            vec![0xcd; 64],
            Some(crate::primitives::evidence::EvidenceEnvelope::Stark(
                StarkProofEnvelope {
                    scheme: "stark".into(),
                    version: 1,
                    proof: vec![0xcd; 64],
                    public_inputs: vec![ByteString(vec![0x01; 32])],
                    verifier_params: None,
                    transcript_tag: None,
                },
            )),
        ).unwrap()
    }
}