use blake3::Hasher;
use curve25519_dalek::{
constants::RISTRETTO_BASEPOINT_POINT,
ristretto::{CompressedRistretto, RistrettoPoint},
scalar::Scalar,
};
use rand::rngs::OsRng;
use serde::{Deserialize, Serialize};
const PEDERSEN_H_DOMAIN: &[u8] = b"SBO3L-Pedersen-H-v1";
const SCHNORR_FS_DOMAIN: &[u8] = b"SBO3L-Schnorr-FS-v1";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ZkCapsulePublicInputs {
pub sbo3l_pubkey_hex: String,
pub challenge_hex: String,
pub request_class: u8,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ZkCapsuleProof {
pub proof_hex: String,
pub vk_id: String,
}
pub trait ZkCapsuleVerifier: Send + Sync {
fn verify(
&self,
public: &ZkCapsulePublicInputs,
proof: &ZkCapsuleProof,
) -> Result<bool, ZkCapsuleVerifyError>;
fn vk_id(&self) -> &str;
}
#[derive(Debug, thiserror::Error)]
pub enum ZkCapsuleVerifyError {
#[error("verifying key id `{0}` is not registered with this verifier")]
UnknownVerifyingKey(String),
#[error("proof is malformed: {0}")]
MalformedProof(String),
#[error("public inputs are malformed: {0}")]
MalformedPublicInputs(String),
#[error("proof verification failed (proof + inputs do not satisfy circuit)")]
Invalid,
#[error("backend not implemented (Groth16 circuit pending)")]
BackendUnavailable,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct PedersenCommitment {
pub bytes: [u8; 32],
}
impl PedersenCommitment {
pub fn to_hex(&self) -> String {
hex::encode(self.bytes)
}
pub fn from_hex(hex: &str) -> Result<Self, CommitmentError> {
let raw = ::hex::decode(hex).map_err(|e| CommitmentError::Hex(e.to_string()))?;
let bytes: [u8; 32] = raw
.try_into()
.map_err(|v: Vec<u8>| CommitmentError::WrongLength(v.len()))?;
Ok(Self { bytes })
}
}
impl Serialize for PedersenCommitment {
fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
self.to_hex().serialize(s)
}
}
impl<'de> Deserialize<'de> for PedersenCommitment {
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let s = String::deserialize(d)?;
Self::from_hex(&s).map_err(serde::de::Error::custom)
}
}
#[derive(Debug, Clone)]
pub struct CommitmentOpening {
pub message: Vec<u8>,
pub blinding: Scalar,
}
impl CommitmentOpening {
pub fn new(message: impl Into<Vec<u8>>) -> Self {
let mut rng = OsRng;
Self {
message: message.into(),
blinding: Scalar::random(&mut rng),
}
}
pub fn blinding_hex(&self) -> String {
hex::encode(self.blinding.to_bytes())
}
pub fn from_parts(message: Vec<u8>, blinding_hex: &str) -> Result<Self, CommitmentError> {
let bytes: [u8; 32] = ::hex::decode(blinding_hex)
.map_err(|e| CommitmentError::Hex(e.to_string()))?
.try_into()
.map_err(|v: Vec<u8>| CommitmentError::WrongLength(v.len()))?;
let blinding = Scalar::from_canonical_bytes(bytes)
.into_option()
.ok_or(CommitmentError::NonCanonicalScalar)?;
Ok(Self { message, blinding })
}
}
#[derive(Debug, thiserror::Error)]
pub enum CommitmentError {
#[error("hex decode error: {0}")]
Hex(String),
#[error("wrong byte length: {0}")]
WrongLength(usize),
#[error("non-canonical scalar")]
NonCanonicalScalar,
#[error("invalid Ristretto point encoding")]
InvalidPoint,
#[error("commitment does not match opening")]
CommitmentMismatch,
#[error("Schnorr proof verification failed")]
SchnorrVerifyFailed,
}
pub fn hash_to_scalar(message: &[u8]) -> Scalar {
let mut h = Hasher::new();
h.update(b"SBO3L-msg-to-scalar-v1");
h.update(message);
let mut wide = [0u8; 64];
let mut xof = h.finalize_xof();
xof.fill(&mut wide);
Scalar::from_bytes_mod_order_wide(&wide)
}
pub fn pedersen_h() -> RistrettoPoint {
RistrettoPoint::hash_from_bytes::<sha2::Sha512>(PEDERSEN_H_DOMAIN)
}
pub fn commit_with_opening(message: impl Into<Vec<u8>>) -> (PedersenCommitment, CommitmentOpening) {
let opening = CommitmentOpening::new(message);
let commitment = commit_from_opening(&opening);
(commitment, opening)
}
pub fn commit_from_opening(opening: &CommitmentOpening) -> PedersenCommitment {
let m = hash_to_scalar(&opening.message);
let g = RISTRETTO_BASEPOINT_POINT;
let h = pedersen_h();
let point = m * g + opening.blinding * h;
PedersenCommitment {
bytes: point.compress().to_bytes(),
}
}
pub fn verify_opening(
commitment: &PedersenCommitment,
opening: &CommitmentOpening,
) -> Result<(), CommitmentError> {
let recomputed = commit_from_opening(opening);
if recomputed.bytes != commitment.bytes {
return Err(CommitmentError::CommitmentMismatch);
}
Ok(())
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SchnorrProofOfOpening {
pub commitment_point_hex: String,
pub response_hex: String,
}
pub fn prove_opening(
commitment: &PedersenCommitment,
opening: &CommitmentOpening,
) -> SchnorrProofOfOpening {
let mut rng = OsRng;
let k = Scalar::random(&mut rng);
let h = pedersen_h();
let big_r = k * h;
let big_r_compressed = big_r.compress();
let challenge = fs_challenge(commitment, &opening.message, &big_r_compressed);
let s = k + challenge * opening.blinding;
SchnorrProofOfOpening {
commitment_point_hex: hex::encode(big_r_compressed.to_bytes()),
response_hex: hex::encode(s.to_bytes()),
}
}
pub fn verify_opening_proof(
commitment: &PedersenCommitment,
message: &[u8],
proof: &SchnorrProofOfOpening,
) -> Result<(), CommitmentError> {
let big_r = decode_point(&proof.commitment_point_hex)?;
let s = decode_scalar(&proof.response_hex)?;
let big_c = decode_compressed_point_bytes(&commitment.bytes)?;
let m = hash_to_scalar(message);
let g = RISTRETTO_BASEPOINT_POINT;
let h = pedersen_h();
let big_r_bytes: [u8; 32] = ::hex::decode(&proof.commitment_point_hex)
.map_err(|e| CommitmentError::Hex(e.to_string()))?
.try_into()
.map_err(|v: Vec<u8>| CommitmentError::WrongLength(v.len()))?;
let big_r_compressed =
CompressedRistretto::from_slice(&big_r_bytes).map_err(|_| CommitmentError::InvalidPoint)?;
let challenge = fs_challenge(commitment, message, &big_r_compressed);
let lhs = s * h;
let rhs = big_r + challenge * (big_c - m * g);
if lhs != rhs {
return Err(CommitmentError::SchnorrVerifyFailed);
}
Ok(())
}
fn fs_challenge(
commitment: &PedersenCommitment,
message: &[u8],
big_r: &CompressedRistretto,
) -> Scalar {
let mut h = Hasher::new();
h.update(SCHNORR_FS_DOMAIN);
h.update(&commitment.bytes);
h.update(&(message.len() as u64).to_le_bytes());
h.update(message);
h.update(big_r.as_bytes());
let mut wide = [0u8; 64];
let mut xof = h.finalize_xof();
xof.fill(&mut wide);
Scalar::from_bytes_mod_order_wide(&wide)
}
fn decode_point(hex: &str) -> Result<RistrettoPoint, CommitmentError> {
let bytes: [u8; 32] = ::hex::decode(hex)
.map_err(|e| CommitmentError::Hex(e.to_string()))?
.try_into()
.map_err(|v: Vec<u8>| CommitmentError::WrongLength(v.len()))?;
decode_compressed_point_bytes(&bytes)
}
fn decode_compressed_point_bytes(bytes: &[u8; 32]) -> Result<RistrettoPoint, CommitmentError> {
CompressedRistretto::from_slice(bytes)
.map_err(|_| CommitmentError::InvalidPoint)?
.decompress()
.ok_or(CommitmentError::InvalidPoint)
}
fn decode_scalar(hex: &str) -> Result<Scalar, CommitmentError> {
let bytes: [u8; 32] = ::hex::decode(hex)
.map_err(|e| CommitmentError::Hex(e.to_string()))?
.try_into()
.map_err(|v: Vec<u8>| CommitmentError::WrongLength(v.len()))?;
Scalar::from_canonical_bytes(bytes)
.into_option()
.ok_or(CommitmentError::NonCanonicalScalar)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn commit_and_verify_round_trip() {
let message = b"sbo3l capsule bytes";
let (commitment, opening) = commit_with_opening(message.to_vec());
verify_opening(&commitment, &opening).unwrap();
}
#[test]
fn commit_hides_message() {
let message = b"identical";
let (c1, _) = commit_with_opening(message.to_vec());
let (c2, _) = commit_with_opening(message.to_vec());
assert_ne!(c1.bytes, c2.bytes);
}
#[test]
fn commit_binds() {
let (c, mut opening) = commit_with_opening(b"original".to_vec());
opening.message = b"tampered".to_vec();
let err = verify_opening(&c, &opening).unwrap_err();
assert!(matches!(err, CommitmentError::CommitmentMismatch));
}
#[test]
fn commit_serialisation_round_trip() {
let (c, _) = commit_with_opening(b"x".to_vec());
let s = serde_json::to_string(&c).unwrap();
let back: PedersenCommitment = serde_json::from_str(&s).unwrap();
assert_eq!(c.bytes, back.bytes);
}
#[test]
fn opening_round_trip_via_blinding_hex() {
let opening = CommitmentOpening::new(b"msg".to_vec());
let message = opening.message.clone();
let blinding_hex = opening.blinding_hex();
let back = CommitmentOpening::from_parts(message.clone(), &blinding_hex).unwrap();
assert_eq!(back.message, message);
assert_eq!(back.blinding, opening.blinding);
}
#[test]
fn pedersen_h_is_stable() {
let h1 = pedersen_h().compress();
let h2 = pedersen_h().compress();
assert_eq!(h1.to_bytes(), h2.to_bytes());
}
#[test]
fn pedersen_h_independent_from_basepoint() {
assert_ne!(
pedersen_h().compress().to_bytes(),
RISTRETTO_BASEPOINT_POINT.compress().to_bytes()
);
}
#[test]
fn schnorr_pok_round_trip() {
let message = b"sbo3l capsule";
let (commitment, opening) = commit_with_opening(message.to_vec());
let proof = prove_opening(&commitment, &opening);
verify_opening_proof(&commitment, message, &proof).unwrap();
}
#[test]
fn schnorr_pok_fails_for_wrong_message() {
let (commitment, opening) = commit_with_opening(b"original".to_vec());
let proof = prove_opening(&commitment, &opening);
let err = verify_opening_proof(&commitment, b"tampered", &proof).unwrap_err();
assert!(matches!(err, CommitmentError::SchnorrVerifyFailed));
}
#[test]
fn schnorr_pok_fails_for_tampered_commitment() {
let (commitment, opening) = commit_with_opening(b"x".to_vec());
let proof = prove_opening(&commitment, &opening);
let mut tampered = commitment;
tampered.bytes[0] ^= 0x01;
let err = verify_opening_proof(&tampered, b"x", &proof);
assert!(err.is_err());
}
#[test]
fn schnorr_pok_fails_for_tampered_response() {
let (commitment, opening) = commit_with_opening(b"x".to_vec());
let mut proof = prove_opening(&commitment, &opening);
let mut response_chars: Vec<char> = proof.response_hex.chars().collect();
let idx = response_chars.len() / 2;
let original = response_chars[idx];
response_chars[idx] = if original == 'a' { 'b' } else { 'a' };
proof.response_hex = response_chars.into_iter().collect();
let err = verify_opening_proof(&commitment, b"x", &proof);
assert!(err.is_err());
}
#[test]
fn schnorr_pok_proofs_for_same_input_differ() {
let (commitment, opening) = commit_with_opening(b"x".to_vec());
let p1 = prove_opening(&commitment, &opening);
let p2 = prove_opening(&commitment, &opening);
assert_ne!(p1.commitment_point_hex, p2.commitment_point_hex);
assert_ne!(p1.response_hex, p2.response_hex);
verify_opening_proof(&commitment, b"x", &p1).unwrap();
verify_opening_proof(&commitment, b"x", &p2).unwrap();
}
#[test]
fn schnorr_pok_serialises_round_trip() {
let (commitment, opening) = commit_with_opening(b"x".to_vec());
let proof = prove_opening(&commitment, &opening);
let s = serde_json::to_string(&proof).unwrap();
let back: SchnorrProofOfOpening = serde_json::from_str(&s).unwrap();
assert_eq!(proof, back);
verify_opening_proof(&commitment, b"x", &back).unwrap();
}
#[test]
fn deny_unknown_fields_in_schnorr_proof() {
let bad = r#"{
"commitment_point_hex": "00",
"response_hex": "00",
"extra": "rejected"
}"#;
let res: Result<SchnorrProofOfOpening, _> = serde_json::from_str(bad);
assert!(res.is_err());
}
#[test]
fn r13_compat_public_inputs_round_trip() {
let p = ZkCapsulePublicInputs {
sbo3l_pubkey_hex: "0".repeat(64),
challenge_hex: "1".repeat(64),
request_class: 0x01,
};
let s = serde_json::to_string(&p).unwrap();
let back: ZkCapsulePublicInputs = serde_json::from_str(&s).unwrap();
assert_eq!(p, back);
}
#[test]
fn r13_compat_proof_round_trip() {
let p = ZkCapsuleProof {
proof_hex: "deadbeef".repeat(64),
vk_id: "vk-v1".to_string(),
};
let s = serde_json::to_string(&p).unwrap();
let back: ZkCapsuleProof = serde_json::from_str(&s).unwrap();
assert_eq!(p, back);
}
}