northroot-proof-engine 0.3.2

Core proof computation and validation library for PoSH (Proof of Shape) and PoX (Proof of Execution)
Documentation
//! Signature handling for v0.2 proof types
//!
//! This module provides ed25519 signature verification and signing functions.
//! Signatures are computed over the SHA-256 hash of the canonicalized commitment.

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};

/// Error returned when signature operations fail
#[derive(Debug, Error, PartialEq, Eq)]
pub enum SignatureError {
    /// Invalid signature format
    #[error("invalid signature format: {0}")]
    InvalidFormat(String),

    /// Signature verification failed
    #[error("signature verification failed: {0}")]
    VerificationFailed(String),

    /// Failed to decode signature or key
    #[error("decoding error: {0}")]
    Decoding(String),
}

/// Validates that a signature has the correct format.
///
/// Checks that alg is "ed25519" and that sig is valid base64url.
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(),
        ));
    }

    // Try to decode base64url signature
    base64::engine::general_purpose::URL_SAFE_NO_PAD
        .decode(&sig.sig)
        .map_err(|e| SignatureError::InvalidFormat(format!("invalid base64url sig: {}", e)))?;

    Ok(())
}

/// Verifies an ed25519 signature over a message.
///
/// The message should be the SHA-256 hash of the canonicalized commitment.
/// The signature is base64url-decoded and verified against the verifying key.
pub fn verify_signature(
    message: &[u8],
    sig: &Signature,
    verifying_key_bytes: &[u8],
) -> Result<bool, SignatureError> {
    validate_signature_format(sig)?;

    // Decode base64url signature
    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)))?;

    // Parse ed25519 signature
    let ed25519_sig = Ed25519Signature::from_bytes(
        sig_bytes
            .as_slice()
            .try_into()
            .map_err(|_| SignatureError::Decoding("signature must be 64 bytes".to_string()))?,
    );

    // Parse verifying key
    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)))?;

    // Verify
    match verifying_key.verify(message, &ed25519_sig) {
        Ok(_) => Ok(true),
        Err(_) => Ok(false), // Verification failed, but return false (not error)
    }
}

/// Verifies a signature over a span commitment.
///
/// This is a convenience function that:
/// 1. Canonicalizes the commitment
/// 2. Computes SHA-256 hash
/// 3. Verifies the signature
pub fn verify_span_commitment_signature(
    commitment: &SpanCommitment,
    sig: &Signature,
    verifying_key_bytes: &[u8],
) -> Result<bool, SignatureError> {
    // Canonicalize and hash
    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)
}

/// Signs a message with a signing key.
///
/// Returns a Signature struct with alg="ed25519" and base64url-encoded signature.
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();

    // Encode as base64url
    let sig_base64 = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(sig_bytes);

    Ok(Signature {
        alg: "ed25519".to_string(),
        kid,
        sig: sig_base64,
    })
}

/// Signs a span commitment.
///
/// This is a convenience function that:
/// 1. Canonicalizes the commitment
/// 2. Computes SHA-256 hash
/// 3. Signs the hash
pub fn sign_span_commitment(
    commitment: &SpanCommitment,
    signing_key: &SigningKey,
    kid: String,
) -> Result<Signature, SignatureError> {
    // Canonicalize and hash
    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();

        // Sign
        let sig = sign_message(message, &signing_key, kid.clone()).unwrap();

        // Verify
        let verified = verify_signature(message, &sig, verifying_key.as_bytes()).unwrap();
        assert!(verified, "Signature should verify");

        // Verify with wrong message
        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,
        };

        // Sign
        let sig = sign_span_commitment(&commitment, &signing_key, kid.clone()).unwrap();

        // Verify
        let verified =
            verify_span_commitment_signature(&commitment, &sig, verifying_key.as_bytes()).unwrap();
        assert!(verified, "Signature should verify");
    }
}