use base64::Engine;
use ed25519_dalek::{Signature as Ed25519Signature, Signer, SigningKey, Verifier, VerifyingKey};
use thiserror::Error;
use crate::hash::compute_sha256;
use crate::types::{Signature, SpanCommitment};
#[derive(Debug, Error, PartialEq, Eq)]
pub enum SignatureError {
#[error("invalid signature format: {0}")]
InvalidFormat(String),
#[error("signature verification failed: {0}")]
VerificationFailed(String),
#[error("decoding error: {0}")]
Decoding(String),
}
pub fn validate_signature_format(sig: &Signature) -> Result<(), SignatureError> {
if sig.alg != "ed25519" {
return Err(SignatureError::InvalidFormat(format!(
"unsupported algorithm: {}",
sig.alg
)));
}
if sig.kid.is_empty() {
return Err(SignatureError::InvalidFormat(
"kid cannot be empty".to_string(),
));
}
if sig.sig.is_empty() {
return Err(SignatureError::InvalidFormat(
"sig cannot be empty".to_string(),
));
}
base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(&sig.sig)
.map_err(|e| SignatureError::InvalidFormat(format!("invalid base64url sig: {}", e)))?;
Ok(())
}
pub fn verify_signature(
message: &[u8],
sig: &Signature,
verifying_key_bytes: &[u8],
) -> Result<bool, SignatureError> {
validate_signature_format(sig)?;
let sig_bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(&sig.sig)
.map_err(|e| SignatureError::Decoding(format!("failed to decode sig: {}", e)))?;
let ed25519_sig = Ed25519Signature::from_bytes(
sig_bytes
.as_slice()
.try_into()
.map_err(|_| SignatureError::Decoding("signature must be 64 bytes".to_string()))?,
);
let verifying_key = VerifyingKey::from_bytes(
verifying_key_bytes
.try_into()
.map_err(|_| SignatureError::Decoding("key must be 32 bytes".to_string()))?,
)
.map_err(|e| SignatureError::Decoding(format!("invalid verifying key: {}", e)))?;
match verifying_key.verify(message, &ed25519_sig) {
Ok(_) => Ok(true),
Err(_) => Ok(false), }
}
pub fn verify_span_commitment_signature(
commitment: &SpanCommitment,
sig: &Signature,
verifying_key_bytes: &[u8],
) -> Result<bool, SignatureError> {
let canonical = crate::canonical::canonicalize_span_commitment(commitment)
.map_err(|e| SignatureError::Decoding(format!("canonicalization failed: {}", e)))?;
let message_hash = compute_sha256(&canonical);
let message_bytes = message_hash.as_bytes();
verify_signature(message_bytes, sig, verifying_key_bytes)
}
pub fn sign_message(
message: &[u8],
signing_key: &SigningKey,
kid: String,
) -> Result<Signature, SignatureError> {
let sig = signing_key.sign(message);
let sig_bytes = sig.to_bytes();
let sig_base64 = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(sig_bytes);
Ok(Signature {
alg: "ed25519".to_string(),
kid,
sig: sig_base64,
})
}
pub fn sign_span_commitment(
commitment: &SpanCommitment,
signing_key: &SigningKey,
kid: String,
) -> Result<Signature, SignatureError> {
let canonical = crate::canonical::canonicalize_span_commitment(commitment)
.map_err(|e| SignatureError::Decoding(format!("canonicalization failed: {}", e)))?;
let message_hash = compute_sha256(&canonical);
let message_bytes = message_hash.as_bytes();
sign_message(message_bytes, signing_key, kid)
}
#[cfg(test)]
mod tests {
use super::*;
use rand::rngs::OsRng;
#[test]
fn test_validate_signature_format_valid() {
let sig = Signature {
alg: "ed25519".to_string(),
kid: "did:key:zTest".to_string(),
sig: base64::engine::general_purpose::URL_SAFE_NO_PAD.encode([0u8; 64]),
};
assert!(validate_signature_format(&sig).is_ok());
}
#[test]
fn test_validate_signature_format_invalid_alg() {
let sig = Signature {
alg: "rsa".to_string(),
kid: "did:key:zTest".to_string(),
sig: base64::engine::general_purpose::URL_SAFE_NO_PAD.encode([0u8; 64]),
};
assert!(validate_signature_format(&sig).is_err());
}
#[test]
fn test_sign_and_verify() {
let mut csprng = OsRng;
let signing_key = SigningKey::generate(&mut csprng);
let verifying_key = signing_key.verifying_key();
let message = b"test message";
let kid = "did:key:zTest".to_string();
let sig = sign_message(message, &signing_key, kid.clone()).unwrap();
let verified = verify_signature(message, &sig, verifying_key.as_bytes()).unwrap();
assert!(verified, "Signature should verify");
let wrong_message = b"wrong message";
let verified_wrong =
verify_signature(wrong_message, &sig, verifying_key.as_bytes()).unwrap();
assert!(
!verified_wrong,
"Signature should not verify for wrong message"
);
}
#[test]
fn test_sign_and_verify_span_commitment() {
use crate::types::{ArtifactRef, MethodRef, SpanCommitment, SpanStatus};
let mut csprng = OsRng;
let signing_key = SigningKey::generate(&mut csprng);
let verifying_key = signing_key.verifying_key();
let kid = "did:key:zExecutor".to_string();
let commitment = SpanCommitment {
schema_version: "proof.v2".to_string(),
trace_id: "tr_01H1234567890".to_string(),
span_id: "sp-0001".to_string(),
method_ref: MethodRef {
method_id: "com.test/method".to_string(),
version: "1.0.0".to_string(),
method_shape_root:
"sha256:0000000000000000000000000000000000000000000000000000000000000000"
.to_string(),
image_digest:
"sha256:0000000000000000000000000000000000000000000000000000000000000000"
.to_string(),
method_fingerprint: "git:deadbeef".to_string(),
},
data_shape_hash:
"sha256:0000000000000000000000000000000000000000000000000000000000000000"
.to_string(),
span_shape_hash:
"sha256:0000000000000000000000000000000000000000000000000000000000000000"
.to_string(),
inputs: vec![ArtifactRef {
name: "input.csv".to_string(),
content_hash:
"sha256:1111111111111111111111111111111111111111111111111111111111111111"
.to_string(),
uri: None,
}],
outputs: vec![ArtifactRef {
name: "output.json".to_string(),
content_hash:
"sha256:2222222222222222222222222222222222222222222222222222222222222222"
.to_string(),
uri: None,
}],
start_time: "2025-11-06T12:00:00.000Z".to_string(),
end_time: "2025-11-06T12:00:00.042Z".to_string(),
status: SpanStatus::OK,
meta: None,
};
let sig = sign_span_commitment(&commitment, &signing_key, kid.clone()).unwrap();
let verified =
verify_span_commitment_signature(&commitment, &sig, verifying_key.as_bytes()).unwrap();
assert!(verified, "Signature should verify");
}
}