use ark_babyjubjub::EdwardsAffine;
use ark_ff::BigInt;
use eddsa_babyjubjub::{EdDSAPrivateKey, EdDSAPublicKey, EdDSASignature};
use rand::Rng;
use ruint::aliases::U256;
use serde::{Deserialize, Deserializer, Serialize, Serializer, de};
use crate::{
FieldElement, PrimitiveError,
poseidon::{self, ds},
sponge::hash_bytes_to_field_element,
};
#[derive(Default, Debug, PartialEq, Eq, Hash, Copy, Clone, Serialize, Deserialize)]
#[repr(u8)]
pub enum CredentialVersion {
#[default]
V1 = 1,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Credential {
pub id: u64,
pub version: CredentialVersion,
#[serde(default)]
pub issuer_version: u8,
pub issuer_schema_id: u64,
pub sub: FieldElement,
pub genesis_issued_at: u64,
pub expires_at: u64,
#[serde(deserialize_with = "deserialize_claims")]
pub claims: Vec<FieldElement>,
#[serde(alias = "associated_data_hash")]
pub associated_data_commitment: FieldElement,
#[serde(serialize_with = "serialize_signature")]
#[serde(deserialize_with = "deserialize_signature")]
#[serde(default)]
pub signature: Option<EdDSASignature>,
#[serde(serialize_with = "serialize_public_key")]
#[serde(deserialize_with = "deserialize_public_key")]
pub issuer: EdDSAPublicKey,
}
impl Credential {
pub const MAX_CLAIMS: usize = 15;
#[must_use]
pub fn new() -> Self {
let mut rng = rand::thread_rng();
Self {
id: rng.r#gen(),
version: CredentialVersion::V1,
issuer_version: 0,
issuer_schema_id: 0,
sub: FieldElement::ZERO,
genesis_issued_at: 0,
expires_at: 0,
claims: vec![FieldElement::ZERO; Self::MAX_CLAIMS],
associated_data_commitment: FieldElement::ZERO,
signature: None,
issuer: EdDSAPublicKey {
pk: EdwardsAffine::default(),
},
}
}
#[must_use]
pub const fn id(mut self, id: u64) -> Self {
self.id = id;
self
}
#[must_use]
pub const fn version(mut self, version: CredentialVersion) -> Self {
self.version = version;
self
}
#[must_use]
pub const fn issuer_version(mut self, issuer_version: u8) -> Self {
self.issuer_version = issuer_version;
self
}
#[must_use]
pub const fn issuer_schema_id(mut self, issuer_schema_id: u64) -> Self {
self.issuer_schema_id = issuer_schema_id;
self
}
#[must_use]
pub const fn subject(mut self, sub: FieldElement) -> Self {
self.sub = sub;
self
}
#[must_use]
pub const fn genesis_issued_at(mut self, genesis_issued_at: u64) -> Self {
self.genesis_issued_at = genesis_issued_at;
self
}
#[must_use]
pub const fn expires_at(mut self, expires_at: u64) -> Self {
self.expires_at = expires_at;
self
}
pub fn claim_hash(mut self, index: usize, claim: U256) -> Result<Self, PrimitiveError> {
if index >= self.claims.len() || index >= Self::MAX_CLAIMS {
return Err(PrimitiveError::OutOfBounds);
}
self.claims[index] = claim.try_into().map_err(|_| PrimitiveError::NotInField)?;
Ok(self)
}
pub fn claim(mut self, index: usize, claim: &[u8]) -> Result<Self, PrimitiveError> {
if index >= self.claims.len() || index >= Self::MAX_CLAIMS {
return Err(PrimitiveError::OutOfBounds);
}
self.claims[index] = hash_bytes_to_field_element(ds::CLAIMS_HASH_V1, claim)?;
Ok(self)
}
pub fn associated_data_commitment(
mut self,
associated_data_commitment: U256,
) -> Result<Self, PrimitiveError> {
self.associated_data_commitment = associated_data_commitment
.try_into()
.map_err(|_| PrimitiveError::NotInField)?;
Ok(self)
}
pub fn associated_data_commitment_from_raw_bytes(
mut self,
data: &[u8],
) -> Result<Self, PrimitiveError> {
self.associated_data_commitment =
hash_bytes_to_field_element(ds::ASSOCIATED_DATA_V1, data)?;
Ok(self)
}
#[must_use]
pub fn get_cred_ds(&self) -> FieldElement {
self.cred_ds().as_field_element()
}
const fn cred_ds(&self) -> crate::DomainSeparator<7> {
match self.version {
CredentialVersion::V1 => ds::CREDENTIAL_V1,
}
}
pub fn claims_hash(&self) -> Result<FieldElement, PrimitiveError> {
if self.claims.len() > Self::MAX_CLAIMS {
return Err(PrimitiveError::OutOfBounds);
}
let mut input = [*FieldElement::ZERO; Self::MAX_CLAIMS + 1]; for (i, claim) in self.claims.iter().enumerate() {
input[i] = **claim;
}
debug_assert_eq!(input[15], *FieldElement::ZERO);
poseidon2::bn254::t16::permutation_in_place(&mut input);
Ok(input[1].into())
}
pub fn hash(&self) -> Result<FieldElement, eyre::Error> {
match self.version {
CredentialVersion::V1 => {
let id_issuer_version = BigInt([self.id, self.issuer_version as u64, 0, 0]);
Ok(poseidon::hash(
self.cred_ds(),
[
self.issuer_schema_id.into(),
self.sub,
self.genesis_issued_at.into(),
self.expires_at.into(),
self.claims_hash()?,
self.associated_data_commitment,
FieldElement::from(ark_babyjubjub::Fq::from(id_issuer_version)),
],
))
}
}
}
pub fn sign(self, signer: &EdDSAPrivateKey) -> Result<Self, eyre::Error> {
let mut credential = self;
credential.signature = Some(signer.sign(*credential.hash()?));
credential.issuer = signer.public();
Ok(credential)
}
pub fn verify_signature(
&self,
expected_issuer_pubkey: &EdDSAPublicKey,
) -> Result<bool, eyre::Error> {
if &self.issuer != expected_issuer_pubkey {
return Err(eyre::eyre!(
"Issuer public key does not match expected public key"
));
}
if let Some(signature) = &self.signature {
return Ok(self.issuer.verify(*self.hash()?, signature));
}
Err(eyre::eyre!("Credential not signed"))
}
#[must_use]
pub fn compute_sub(leaf_index: u64, blinding_factor: FieldElement) -> FieldElement {
poseidon::hash(ds::CREDENTIAL_SUB, [leaf_index.into(), blinding_factor])
}
}
impl Default for Credential {
fn default() -> Self {
Self::new()
}
}
#[expect(clippy::ref_option)]
fn serialize_signature<S>(
signature: &Option<EdDSASignature>,
serializer: S,
) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
let Some(signature) = signature else {
return serializer.serialize_none();
};
let sig = signature
.to_compressed_bytes()
.map_err(serde::ser::Error::custom)?;
if serializer.is_human_readable() {
serializer.serialize_str(&hex::encode(sig))
} else {
serializer.serialize_bytes(&sig)
}
}
fn deserialize_signature<'de, D>(deserializer: D) -> Result<Option<EdDSASignature>, D::Error>
where
D: Deserializer<'de>,
{
let bytes: Option<Vec<u8>> = if deserializer.is_human_readable() {
Option::<String>::deserialize(deserializer)?
.map(|s| hex::decode(s).map_err(de::Error::custom))
.transpose()?
} else {
Option::<Vec<u8>>::deserialize(deserializer)?
};
let Some(bytes) = bytes else {
return Ok(None);
};
if bytes.len() != 64 {
return Err(de::Error::custom("Invalid signature. Expected 64 bytes."));
}
let mut arr = [0u8; 64];
arr.copy_from_slice(&bytes);
EdDSASignature::from_compressed_bytes(arr)
.map(Some)
.map_err(de::Error::custom)
}
fn deserialize_claims<'de, D>(deserializer: D) -> Result<Vec<FieldElement>, D::Error>
where
D: Deserializer<'de>,
{
let mut claims = Vec::<FieldElement>::deserialize(deserializer)?;
if claims.len() == Credential::MAX_CLAIMS + 1 && claims.last() == Some(&FieldElement::ZERO) {
claims.pop();
}
if claims.len() > Credential::MAX_CLAIMS {
return Err(de::Error::custom(format!(
"invalid credential: {} claims provided, at most {} are allowed",
claims.len(),
Credential::MAX_CLAIMS
)));
}
Ok(claims)
}
fn serialize_public_key<S>(public_key: &EdDSAPublicKey, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
let pk = public_key
.to_compressed_bytes()
.map_err(serde::ser::Error::custom)?;
if serializer.is_human_readable() {
serializer.serialize_str(&hex::encode(pk))
} else {
serializer.serialize_bytes(&pk)
}
}
fn deserialize_public_key<'de, D>(deserializer: D) -> Result<EdDSAPublicKey, D::Error>
where
D: Deserializer<'de>,
{
let bytes: Vec<u8> = if deserializer.is_human_readable() {
hex::decode(String::deserialize(deserializer)?).map_err(de::Error::custom)?
} else {
Vec::<u8>::deserialize(deserializer)?
};
if bytes.len() != 32 {
return Err(de::Error::custom("Invalid public key. Expected 32 bytes."));
}
let mut arr = [0u8; 32];
arr.copy_from_slice(&bytes);
EdDSAPublicKey::from_compressed_bytes(arr).map_err(de::Error::custom)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_deterministic_credential_hash() {
let mut credential = Credential::new();
credential.id = 1;
assert_eq!(
hex::encode(credential.hash().unwrap().to_be_bytes()),
"2bc705762cbe8f31e0c3045ca347109ba3630b4b7ea955dc71515f182a079ae9"
);
}
#[test]
fn test_associated_data_matches_direct_hash() {
let data = vec![1u8, 2, 3, 4, 5, 6, 7, 8, 9, 10];
let credential = Credential::new()
.associated_data_commitment_from_raw_bytes(&data)
.unwrap();
let direct_hash = hash_bytes_to_field_element(ds::ASSOCIATED_DATA_V1, &data).unwrap();
assert_eq!(credential.associated_data_commitment, direct_hash);
}
#[test]
fn test_associated_data_method() {
let data = vec![1u8, 2, 3, 4, 5, 6, 7, 8];
let credential = Credential::new()
.associated_data_commitment_from_raw_bytes(&data)
.unwrap();
assert_ne!(credential.associated_data_commitment, FieldElement::ZERO);
}
#[test]
fn test_claim_matches_direct_hash() {
let data = vec![1u8, 2, 3, 4, 5, 6, 7, 8, 9, 10];
let credential = Credential::new().claim(0, &data).unwrap();
let direct_hash = hash_bytes_to_field_element(ds::CLAIMS_HASH_V1, &data).unwrap();
assert_eq!(credential.claims[0], direct_hash);
}
#[test]
fn test_v1_cannot_set_last_element_of_claims() {
let err = Credential::new()
.claim_hash(15, U256::from(42))
.unwrap_err();
assert!(matches!(err, PrimitiveError::OutOfBounds));
let err = Credential::new().claim(15, b"out of bounds").unwrap_err();
assert!(matches!(err, PrimitiveError::OutOfBounds));
Credential::new().claim_hash(14, U256::from(42)).unwrap();
}
#[test]
fn test_v1_cannot_set_reserved_element_via_oversized_claims_vec() {
let mut cred = Credential::new();
cred.claims = vec![FieldElement::ZERO; Credential::MAX_CLAIMS + 5];
let err = cred.clone().claim_hash(15, U256::from(42)).unwrap_err();
assert!(matches!(err, PrimitiveError::OutOfBounds));
let err = cred.claim(15, b"out of bounds").unwrap_err();
assert!(matches!(err, PrimitiveError::OutOfBounds));
}
#[test]
fn test_claims_hash_rejects_more_than_15_claims_and_last_element_is_zero() {
let mut cred = Credential::new();
cred.claims = vec![FieldElement::from(42u64); Credential::MAX_CLAIMS];
let mut expected = [*FieldElement::ZERO; Credential::MAX_CLAIMS + 1];
for element in expected.iter_mut().take(Credential::MAX_CLAIMS) {
*element = *FieldElement::from(42u64);
}
assert_eq!(expected[15], *FieldElement::ZERO);
poseidon2::bn254::t16::permutation_in_place(&mut expected);
assert_eq!(cred.claims_hash().unwrap(), FieldElement::from(expected[1]));
let mut cred = Credential::new();
cred.claims = vec![FieldElement::from(42u64); Credential::MAX_CLAIMS + 1];
let err = cred.claims_hash().unwrap_err();
assert!(matches!(err, PrimitiveError::OutOfBounds));
}
#[test]
fn test_claim_hash_rejects_value_above_field_modulus() {
let err = Credential::new().claim_hash(0, U256::MAX).unwrap_err();
assert!(matches!(err, PrimitiveError::NotInField));
}
#[test]
fn test_claim_rejects_empty_data() {
let err = Credential::new().claim(0, &[]).unwrap_err();
assert!(matches!(err, PrimitiveError::InvalidInput { .. }));
}
#[test]
fn test_deserialize_rejects_too_many_claims() {
let mut credential = Credential::new();
credential.claims = vec![FieldElement::from(7u64); Credential::MAX_CLAIMS];
let mut json: serde_json::Value =
serde_json::from_str(&serde_json::to_string(&credential).unwrap()).unwrap();
json["claims"]
.as_array_mut()
.unwrap()
.push(serde_json::json!(FieldElement::from(7u64).to_string()));
let err = serde_json::from_value::<Credential>(json).unwrap_err();
assert!(
err.to_string().contains("at most"),
"unexpected error: {err}"
);
}
#[test]
fn test_deserialize_accepts_legacy_trailing_zero_claim() {
let signer = EdDSAPrivateKey::random(&mut rand::thread_rng());
let mut credential = Credential::new();
credential.id = 1;
credential.claims = vec![FieldElement::from(7u64); Credential::MAX_CLAIMS];
let credential = credential.sign(&signer).unwrap();
let mut json: serde_json::Value =
serde_json::from_str(&serde_json::to_string(&credential).unwrap()).unwrap();
json["claims"]
.as_array_mut()
.unwrap()
.push(serde_json::json!(FieldElement::ZERO.to_string()));
let decoded: Credential = serde_json::from_value(json).unwrap();
assert_eq!(decoded.claims.len(), Credential::MAX_CLAIMS);
assert_eq!(decoded.hash().unwrap(), credential.hash().unwrap());
assert!(decoded.verify_signature(&signer.public()).unwrap());
}
#[test]
fn test_claim_method() {
let data = vec![1u8, 2, 3, 4, 5, 6, 7, 8];
let credential = Credential::new().claim(1, &data).unwrap();
assert_ne!(credential.claims[1], FieldElement::ZERO);
}
#[test]
fn test_issuer_version_is_bound_to_credential_hash() {
let mut credential = Credential::new();
credential.id = 1;
credential.issuer_version = 1;
let mut tampered = credential.clone();
tampered.issuer_version = 2;
let original_hash = credential.hash().unwrap();
let tampered_hash = tampered.hash().unwrap();
assert_ne!(original_hash, tampered_hash);
let signer = EdDSAPrivateKey::random(&mut rand::thread_rng());
let signed = credential.sign(&signer).unwrap();
let issuer_pubkey = signer.public();
assert!(signed.verify_signature(&issuer_pubkey).unwrap());
let mut tampered_signed = signed.clone();
tampered_signed.issuer_version = signed.issuer_version.wrapping_add(1);
assert!(
!tampered_signed.verify_signature(&issuer_pubkey).unwrap(),
"tampering with issuer_version must invalidate the signature"
);
}
}