use chrono::{DateTime, Utc};
use crypto_suites::CryptoSuite;
use multibase::Base;
use serde::{Deserialize, Serialize};
use serde_json_canonicalizer::to_string;
use sha2::{Digest, Sha256};
use signer::Signer;
use tracing::debug;
pub mod caching_signer;
pub mod conformance;
pub mod crypto_suites;
pub mod did_vm;
pub mod error;
pub mod multi;
pub mod options;
pub mod signer;
pub mod suite_ops;
pub mod verification_proof;
pub use caching_signer::{CachingSigner, GetPrivateBytes};
pub use conformance::verify_conformance;
pub use did_vm::{DidKeyResolver, ResolvedKey, VerificationMethodResolver};
pub use multi::{MultiVerifyResult, VerifyPolicy, verify_multi};
#[cfg(feature = "bbs-2023")]
pub mod bbs_2023;
#[cfg(feature = "bbs-2023")]
pub mod bbs_2023_transform;
pub use error::{DataIntegrityError, SignatureFailure};
pub use options::{SignOptions, VerifyOptions};
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DataIntegrityProof {
#[serde(rename = "type")]
pub type_: String,
pub cryptosuite: CryptoSuite,
#[serde(skip_serializing_if = "Option::is_none")]
pub created: Option<String>,
pub verification_method: String,
pub proof_purpose: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub proof_value: Option<String>,
#[serde(rename = "@context", skip_serializing_if = "Option::is_none")]
pub context: Option<Vec<String>>,
}
impl DataIntegrityProof {
pub async fn sign<S>(
data_doc: &S,
signer: &dyn Signer,
options: SignOptions,
) -> Result<DataIntegrityProof, DataIntegrityError>
where
S: Serialize,
{
let crypto_suite = options.cryptosuite.unwrap_or_else(|| signer.cryptosuite());
crypto_suite
.validate_key_type(signer.key_type())
.map_err(|_| DataIntegrityError::KeyTypeMismatch {
expected: crypto_suite
.compatible_key_types()
.first()
.copied()
.unwrap_or(affinidi_secrets_resolver::secrets::KeyType::Unknown),
actual: signer.key_type(),
suite: crypto_suite,
})?;
let created_str = options
.created
.map(format_created)
.unwrap_or_else(|| format_created(Utc::now()));
let proof_purpose = options
.proof_purpose
.unwrap_or_else(|| "assertionMethod".to_string());
if crypto_suite.is_rdfc() {
sign_rdfc(
data_doc,
crypto_suite,
options.context,
signer,
created_str,
proof_purpose,
)
.await
} else {
sign_jcs(
data_doc,
crypto_suite,
options.context,
signer,
created_str,
proof_purpose,
)
.await
}
}
#[must_use = "ignoring a verification result is a security bug"]
pub fn verify_with_public_key<S>(
&self,
data_doc: &S,
public_key_bytes: &[u8],
options: VerifyOptions,
) -> Result<(), DataIntegrityError>
where
S: Serialize,
{
verify_proof_internal(self, data_doc, public_key_bytes, &options)
}
#[must_use = "ignoring a verification result is a security bug"]
pub async fn verify<S, R>(
&self,
data_doc: &S,
resolver: &R,
options: VerifyOptions,
) -> Result<(), DataIntegrityError>
where
S: Serialize + Sync,
R: VerificationMethodResolver + ?Sized,
{
let resolved = resolver.resolve_vm(&self.verification_method).await?;
let compatible = self.cryptosuite.compatible_key_types();
if !compatible.is_empty() && !compatible.contains(&resolved.key_type) {
return Err(DataIntegrityError::KeyTypeMismatch {
expected: compatible
.first()
.copied()
.unwrap_or(affinidi_secrets_resolver::secrets::KeyType::Unknown),
actual: resolved.key_type,
suite: self.cryptosuite,
});
}
verify_proof_internal(self, data_doc, &resolved.public_key_bytes, &options)
}
}
async fn sign_jcs<S>(
data_doc: &S,
crypto_suite: CryptoSuite,
context: Option<Vec<String>>,
signer: &dyn Signer,
created: String,
proof_purpose: String,
) -> Result<DataIntegrityProof, DataIntegrityError>
where
S: Serialize,
{
let jcs = to_string(data_doc)
.map_err(|e| DataIntegrityError::Canonicalization(format!("document: {e}")))?;
debug!("Document (JCS): {}", jcs);
let mut proof_options = DataIntegrityProof {
type_: "DataIntegrityProof".to_string(),
cryptosuite: crypto_suite,
created: Some(created),
verification_method: signer.verification_method().to_string(),
proof_purpose,
proof_value: None,
context,
};
let proof_jcs = to_string(&proof_options)
.map_err(|e| DataIntegrityError::Canonicalization(format!("proof config: {e}")))?;
debug!("Proof options (JCS): {}", proof_jcs);
let hash_data = hashing_jcs(&jcs, &proof_jcs);
let signed = signer.sign(&hash_data).await?;
proof_options.proof_value = Some(multibase::encode(Base::Base58Btc, &signed));
Ok(proof_options)
}
async fn sign_rdfc<S>(
data_doc: &S,
crypto_suite: CryptoSuite,
context: Option<Vec<String>>,
signer: &dyn Signer,
created: String,
proof_purpose: String,
) -> Result<DataIntegrityProof, DataIntegrityError>
where
S: Serialize,
{
let doc_value = serde_json::to_value(data_doc)
.map_err(|e| DataIntegrityError::Canonicalization(format!("document serialize: {e}")))?;
let proof_context = if let Some(ctx) = context {
Some(ctx)
} else {
match doc_value.get("@context") {
Some(serde_json::Value::Array(arr)) => Some(
arr.iter()
.filter_map(|v| v.as_str().map(str::to_string))
.collect(),
),
Some(serde_json::Value::String(s)) => Some(vec![s.clone()]),
Some(_) => {
return Err(DataIntegrityError::MalformedProof(
"Invalid @context format in document".to_string(),
));
}
None => {
return Err(DataIntegrityError::MalformedProof(
"Document must contain @context for RDFC signing".to_string(),
));
}
}
};
let mut proof_options = DataIntegrityProof {
type_: "DataIntegrityProof".to_string(),
cryptosuite: crypto_suite,
created: Some(created),
verification_method: signer.verification_method().to_string(),
proof_purpose,
proof_value: None,
context: proof_context,
};
let proof_value = serde_json::to_value(&proof_options).map_err(|e| {
DataIntegrityError::Canonicalization(format!("proof config serialize: {e}"))
})?;
let hash_data = hashing_rdfc(&doc_value, &proof_value)?;
let signed = signer.sign(&hash_data).await?;
proof_options.proof_value = Some(multibase::encode(Base::Base58Btc, &signed));
Ok(proof_options)
}
fn verify_proof_internal<S>(
proof: &DataIntegrityProof,
signed_doc: &S,
public_key_bytes: &[u8],
options: &VerifyOptions,
) -> Result<(), DataIntegrityError>
where
S: Serialize,
{
if !options.allowed_suites.is_empty() && !options.allowed_suites.contains(&proof.cryptosuite) {
return Err(DataIntegrityError::Conformance(format!(
"cryptosuite {} is not in the caller's allowed suites",
String::try_from(proof.cryptosuite).unwrap_or_default()
)));
}
if let Some(expected) = &options.expected_context
&& proof.context.as_ref() != Some(expected)
{
return Err(DataIntegrityError::Conformance(
"Document context does not match proof context".to_string(),
));
}
let Some(proof_value) = &proof.proof_value else {
return Err(DataIntegrityError::MalformedProof(
"proofValue is missing in the proof".to_string(),
));
};
let proof_value = multibase::decode(proof_value)
.map_err(|e| DataIntegrityError::MalformedProof(format!("Invalid proof value: {e}")))?
.1;
let proof_config = DataIntegrityProof {
proof_value: None,
..proof.clone()
};
if proof_config.type_ != "DataIntegrityProof" {
return Err(DataIntegrityError::Conformance(
"Invalid proof type, expected 'DataIntegrityProof'".to_string(),
));
}
if let Some(created) = &proof_config.created {
let now = Utc::now();
let created = created
.parse::<DateTime<Utc>>()
.map_err(|e| DataIntegrityError::Conformance(format!("Invalid created date: {e}")))?;
if created > now {
return Err(DataIntegrityError::Conformance(
"Created date is in the future".to_string(),
));
}
}
let hash_data = if proof_config.cryptosuite.is_rdfc() {
let doc_value = serde_json::to_value(signed_doc).map_err(|e| {
DataIntegrityError::Canonicalization(format!("document serialize: {e}"))
})?;
let proof_value_json = serde_json::to_value(&proof_config).map_err(|e| {
DataIntegrityError::Canonicalization(format!("proof config serialize: {e}"))
})?;
hashing_rdfc(&doc_value, &proof_value_json)?
} else {
#[cfg(feature = "bbs-2023")]
if matches!(proof_config.cryptosuite, CryptoSuite::Bbs2023) {
return Err(DataIntegrityError::UnsupportedCryptoSuite {
name: "bbs-2023 proofs must be verified via bbs_2023::verify_proof".to_string(),
});
}
let jcs_doc = to_string(&signed_doc)
.map_err(|e| DataIntegrityError::Canonicalization(format!("document: {e}")))?;
let jcs_proof_config = to_string(&proof_config)
.map_err(|e| DataIntegrityError::Canonicalization(format!("proof config: {e}")))?;
hashing_jcs(&jcs_doc, &jcs_proof_config)
};
proof_config
.cryptosuite
.verify(public_key_bytes, &hash_data, &proof_value)
}
fn hashing_jcs(transformed_document: &str, canonical_proof_config: &str) -> Vec<u8> {
[
Sha256::digest(canonical_proof_config),
Sha256::digest(transformed_document),
]
.concat()
}
fn hashing_rdfc(
document: &serde_json::Value,
proof_config: &serde_json::Value,
) -> Result<Vec<u8>, DataIntegrityError> {
let doc_hash = affinidi_rdf_encoding::expand_canonicalize_and_hash(document)
.map_err(|e| DataIntegrityError::Canonicalization(format!("RDFC document hash: {e}")))?;
let proof_hash =
affinidi_rdf_encoding::expand_canonicalize_and_hash(proof_config).map_err(|e| {
DataIntegrityError::Canonicalization(format!("RDFC proof config hash: {e}"))
})?;
Ok([proof_hash.as_slice(), doc_hash.as_slice()].concat())
}
pub fn prepare_sign_input<S>(
data_doc: &S,
proof_config: &DataIntegrityProof,
cryptosuite: CryptoSuite,
) -> Result<Vec<u8>, DataIntegrityError>
where
S: Serialize,
{
if cryptosuite.is_rdfc() {
let doc_value = serde_json::to_value(data_doc).map_err(|e| {
DataIntegrityError::Canonicalization(format!("document serialize: {e}"))
})?;
let proof_value = serde_json::to_value(proof_config).map_err(|e| {
DataIntegrityError::Canonicalization(format!("proof config serialize: {e}"))
})?;
hashing_rdfc(&doc_value, &proof_value)
} else {
let jcs_doc = to_string(data_doc)
.map_err(|e| DataIntegrityError::Canonicalization(format!("document: {e}")))?;
let jcs_proof = to_string(proof_config)
.map_err(|e| DataIntegrityError::Canonicalization(format!("proof config: {e}")))?;
Ok(hashing_jcs(&jcs_doc, &jcs_proof))
}
}
fn format_created(dt: DateTime<Utc>) -> String {
dt.to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
}
#[cfg(test)]
mod tests {
use affinidi_secrets_resolver::secrets::Secret;
use serde_json::json;
use crate::{DataIntegrityProof, SignOptions, VerifyOptions, hashing_jcs};
#[test]
fn hashing_working() {
let hash = hashing_jcs("test1", "test2");
let mut output = String::new();
for x in hash {
output.push_str(&format!("{x:02x}"));
}
assert_eq!(
output.as_str(),
"60303ae22b998861bce3b28f33eec1be758a213c86c93c076dbe9f558c11c7521b4f0e9851971998e732078544c96b36c3d01cedf7caa332359d6f1d83567014",
);
}
#[tokio::test]
async fn sign_and_verify_via_did_key_resolver_ed25519() {
use crate::{DidKeyResolver, VerifyOptions};
let secret = Secret::generate_ed25519(None, Some(&[11u8; 32]));
let pk_mb = secret.get_public_keymultibase().unwrap();
let mut signer_secret = secret.clone();
signer_secret.id = format!("did:key:{pk_mb}#{pk_mb}");
let doc = json!({ "hello": "did:key" });
let proof = DataIntegrityProof::sign(&doc, &signer_secret, SignOptions::new())
.await
.expect("sign");
proof
.verify(&doc, &DidKeyResolver, VerifyOptions::new())
.await
.expect("verify via resolver");
}
#[cfg(feature = "ml-dsa")]
#[tokio::test]
async fn sign_and_verify_via_did_key_resolver_ml_dsa() {
use crate::{DidKeyResolver, VerifyOptions};
let secret = Secret::generate_ml_dsa_44(None, Some(&[21u8; 32]));
let pk_mb = secret.get_public_keymultibase().unwrap();
let mut signer_secret = secret.clone();
signer_secret.id = format!("did:key:{pk_mb}#{pk_mb}");
let doc = json!({ "pqc": "did:key" });
let proof = DataIntegrityProof::sign(&doc, &signer_secret, SignOptions::new())
.await
.expect("sign");
proof
.verify(&doc, &DidKeyResolver, VerifyOptions::new())
.await
.expect("verify via resolver");
}
#[tokio::test]
async fn unified_sign_verify_ed25519_jcs() {
let secret = Secret::generate_ed25519(Some("did:key:k#k"), Some(&[4u8; 32]));
let doc = json!({"hello": "world"});
let proof = DataIntegrityProof::sign(&doc, &secret, SignOptions::new())
.await
.expect("sign");
proof
.verify_with_public_key(&doc, secret.get_public_bytes(), VerifyOptions::new())
.expect("verify");
}
#[cfg(feature = "ml-dsa")]
#[tokio::test]
async fn unified_sign_verify_ml_dsa_44_jcs() {
let secret = Secret::generate_ml_dsa_44(Some("did:key:k#k"), Some(&[8u8; 32]));
let doc = json!({"pqc": true});
let proof = DataIntegrityProof::sign(&doc, &secret, SignOptions::new())
.await
.expect("sign");
assert_eq!(
proof.cryptosuite,
crate::crypto_suites::CryptoSuite::MlDsa44Jcs2024
);
proof
.verify_with_public_key(&doc, secret.get_public_bytes(), VerifyOptions::new())
.expect("verify");
}
#[cfg(feature = "ml-dsa")]
#[tokio::test]
async fn override_suite_via_sign_options() {
let secret = Secret::generate_ed25519(Some("did:key:k#k"), Some(&[1u8; 32]));
let doc = json!({"x": 1});
let err = DataIntegrityProof::sign(
&doc,
&secret,
SignOptions::new().with_cryptosuite(crate::crypto_suites::CryptoSuite::MlDsa44Jcs2024),
)
.await
.unwrap_err();
assert!(matches!(
err,
crate::DataIntegrityError::KeyTypeMismatch { .. }
));
}
#[tokio::test]
async fn verify_rejects_cryptosuite_tampering() {
use crate::crypto_suites::CryptoSuite;
let secret = Secret::generate_ed25519(Some("did:key:k#k"), Some(&[77u8; 32]));
let doc = json!({"tamper": "target"});
let mut proof = DataIntegrityProof::sign(&doc, &secret, SignOptions::new())
.await
.expect("sign");
assert_eq!(proof.cryptosuite, CryptoSuite::EddsaJcs2022);
proof.cryptosuite = CryptoSuite::EddsaRdfc2022;
let err = proof
.verify_with_public_key(&doc, secret.get_public_bytes(), VerifyOptions::new())
.unwrap_err();
assert!(
matches!(err, crate::DataIntegrityError::InvalidSignature { .. }),
"expected InvalidSignature after cryptosuite tampering, got: {err:?}"
);
}
#[tokio::test]
async fn deterministic_signing_same_input_same_output() {
let secret = Secret::generate_ed25519(Some("did:key:k#k"), Some(&[2u8; 32]));
let doc = json!({"deterministic": "yes"});
let created = chrono::Utc::now();
let opts = || SignOptions::new().with_created(created);
let a = DataIntegrityProof::sign(&doc, &secret, opts())
.await
.unwrap();
let b = DataIntegrityProof::sign(&doc, &secret, opts())
.await
.unwrap();
assert_eq!(
a.proof_value, b.proof_value,
"Ed25519 must be deterministic"
);
}
#[cfg(feature = "ml-dsa")]
#[tokio::test]
async fn deterministic_signing_ml_dsa() {
let secret = Secret::generate_ml_dsa_44(Some("did:key:k#k"), Some(&[5u8; 32]));
let doc = json!({"deterministic": "pqc"});
let created = chrono::Utc::now();
let opts = || SignOptions::new().with_created(created);
let a = DataIntegrityProof::sign(&doc, &secret, opts())
.await
.unwrap();
let b = DataIntegrityProof::sign(&doc, &secret, opts())
.await
.unwrap();
assert_eq!(a.proof_value, b.proof_value, "ML-DSA must be deterministic");
}
#[tokio::test]
async fn test_sign_bad_key() {
let generic_doc = json!({"test": "test_data"});
let pub_key = "zruqgFba156mDWfMUjJUSAKUvgCgF5NfgSYwSuEZuXpixts8tw3ot5BasjeyM65f8dzk5k6zgXf7pkbaaBnPrjCUmcJ";
let pri_key = "z42tmXtqqQBLmEEwn8tfi1bA2ghBx9cBo6wo8a44kVJEiqyA";
let secret = Secret::from_multibase(pri_key, Some(&format!("did:key:{pub_key}#{pub_key}")))
.expect("Couldn't create test key data");
assert!(
DataIntegrityProof::sign(&generic_doc, &secret, SignOptions::new())
.await
.is_err()
);
}
#[tokio::test]
async fn test_sign_good() {
let generic_doc = json!({"test": "test_data"});
let pub_key = "z6MktDNePDZTvVcF5t6u362SsonU7HkuVFSMVCjSspQLDaBm";
let pri_key = "z3u2UQyiY96d7VQaua8yiaSyQxq5Z5W5Qkpz7o2H2pc9BkEa";
let secret = Secret::from_multibase(pri_key, Some(&format!("did:key:{pub_key}#{pub_key}")))
.expect("Couldn't create test key data");
let context = vec![
"context1".to_string(),
"context2".to_string(),
"context3".to_string(),
];
assert!(
DataIntegrityProof::sign(
&generic_doc,
&secret,
SignOptions::new().with_context(context)
)
.await
.is_ok(),
"Signing failed"
);
}
#[cfg(feature = "ml-dsa")]
#[tokio::test]
async fn sign_verify_jcs_ml_dsa_44() {
use crate::crypto_suites::CryptoSuite;
let secret = Secret::generate_ml_dsa_44(Some("k-did#k-did"), Some(&[5u8; 32]));
let doc = json!({"hello": "pqc"});
let proof = DataIntegrityProof::sign(
&doc,
&secret,
SignOptions::new().with_cryptosuite(CryptoSuite::MlDsa44Jcs2024),
)
.await
.expect("sign ml-dsa");
assert_eq!(proof.cryptosuite, CryptoSuite::MlDsa44Jcs2024);
proof
.verify_with_public_key(&doc, secret.get_public_bytes(), VerifyOptions::new())
.expect("verify ml-dsa");
}
#[cfg(feature = "ml-dsa")]
#[tokio::test]
async fn sign_wrong_suite_for_key_fails() {
use crate::crypto_suites::CryptoSuite;
let secret = Secret::generate_ml_dsa_44(Some("k"), Some(&[1u8; 32]));
let doc = json!({"x": 1});
let err = DataIntegrityProof::sign(
&doc,
&secret,
SignOptions::new().with_cryptosuite(CryptoSuite::EddsaJcs2022),
)
.await;
assert!(err.is_err());
}
#[cfg(feature = "slh-dsa")]
#[tokio::test]
async fn sign_verify_jcs_slh_dsa_128s() {
use crate::crypto_suites::CryptoSuite;
let secret = Secret::generate_slh_dsa_sha2_128s(Some("k#k"));
let doc = json!({"hello": "slh"});
let proof = DataIntegrityProof::sign(
&doc,
&secret,
SignOptions::new().with_cryptosuite(CryptoSuite::SlhDsa128Jcs2024),
)
.await
.expect("sign slh-dsa");
proof
.verify_with_public_key(&doc, secret.get_public_bytes(), VerifyOptions::new())
.expect("verify slh-dsa");
}
}