use merlin::Transcript;
use crate::claim::Claim;
use crate::codec::to_cbor_canonical;
use crate::error::TranscriptError;
const DOMAIN_TAG: &[u8] = b"vauban-claim-v1-transcript-t1";
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";
}
#[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);
t.append_message(labels::DOMAIN, DOMAIN_TAG);
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),
}
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());
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());
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),
}
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);
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());
t.append_message(labels::VERIFIER_CTX, verifier_ctx);
t.append_message(labels::PRESENTATION_NONCE, nonce);
Ok(t)
}
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;
#[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");
}
#[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");
}
#[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");
}
#[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");
}
#[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);
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);
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"
);
}
#[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()
}
}