Skip to main content

northroot_proof_engine/
signature.rs

1//! Signature handling for v0.2 proof types
2//!
3//! This module provides ed25519 signature verification and signing functions.
4//! Signatures are computed over the SHA-256 hash of the canonicalized commitment.
5
6use base64::Engine;
7use ed25519_dalek::{Signature as Ed25519Signature, Signer, SigningKey, Verifier, VerifyingKey};
8use thiserror::Error;
9
10use crate::hash::compute_sha256;
11use crate::types::{Signature, SpanCommitment};
12
13/// Error returned when signature operations fail
14#[derive(Debug, Error, PartialEq, Eq)]
15pub enum SignatureError {
16    /// Invalid signature format
17    #[error("invalid signature format: {0}")]
18    InvalidFormat(String),
19
20    /// Signature verification failed
21    #[error("signature verification failed: {0}")]
22    VerificationFailed(String),
23
24    /// Failed to decode signature or key
25    #[error("decoding error: {0}")]
26    Decoding(String),
27}
28
29/// Validates that a signature has the correct format.
30///
31/// Checks that alg is "ed25519" and that sig is valid base64url.
32pub fn validate_signature_format(sig: &Signature) -> Result<(), SignatureError> {
33    if sig.alg != "ed25519" {
34        return Err(SignatureError::InvalidFormat(format!(
35            "unsupported algorithm: {}",
36            sig.alg
37        )));
38    }
39
40    if sig.kid.is_empty() {
41        return Err(SignatureError::InvalidFormat(
42            "kid cannot be empty".to_string(),
43        ));
44    }
45
46    if sig.sig.is_empty() {
47        return Err(SignatureError::InvalidFormat(
48            "sig cannot be empty".to_string(),
49        ));
50    }
51
52    // Try to decode base64url signature
53    base64::engine::general_purpose::URL_SAFE_NO_PAD
54        .decode(&sig.sig)
55        .map_err(|e| SignatureError::InvalidFormat(format!("invalid base64url sig: {}", e)))?;
56
57    Ok(())
58}
59
60/// Verifies an ed25519 signature over a message.
61///
62/// The message should be the SHA-256 hash of the canonicalized commitment.
63/// The signature is base64url-decoded and verified against the verifying key.
64pub fn verify_signature(
65    message: &[u8],
66    sig: &Signature,
67    verifying_key_bytes: &[u8],
68) -> Result<bool, SignatureError> {
69    validate_signature_format(sig)?;
70
71    // Decode base64url signature
72    let sig_bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
73        .decode(&sig.sig)
74        .map_err(|e| SignatureError::Decoding(format!("failed to decode sig: {}", e)))?;
75
76    // Parse ed25519 signature
77    let ed25519_sig = Ed25519Signature::from_bytes(
78        sig_bytes
79            .as_slice()
80            .try_into()
81            .map_err(|_| SignatureError::Decoding("signature must be 64 bytes".to_string()))?,
82    );
83
84    // Parse verifying key
85    let verifying_key = VerifyingKey::from_bytes(
86        verifying_key_bytes
87            .try_into()
88            .map_err(|_| SignatureError::Decoding("key must be 32 bytes".to_string()))?,
89    )
90    .map_err(|e| SignatureError::Decoding(format!("invalid verifying key: {}", e)))?;
91
92    // Verify
93    match verifying_key.verify(message, &ed25519_sig) {
94        Ok(_) => Ok(true),
95        Err(_) => Ok(false), // Verification failed, but return false (not error)
96    }
97}
98
99/// Verifies a signature over a span commitment.
100///
101/// This is a convenience function that:
102/// 1. Canonicalizes the commitment
103/// 2. Computes SHA-256 hash
104/// 3. Verifies the signature
105pub fn verify_span_commitment_signature(
106    commitment: &SpanCommitment,
107    sig: &Signature,
108    verifying_key_bytes: &[u8],
109) -> Result<bool, SignatureError> {
110    // Canonicalize and hash
111    let canonical = crate::canonical::canonicalize_span_commitment(commitment)
112        .map_err(|e| SignatureError::Decoding(format!("canonicalization failed: {}", e)))?;
113    let message_hash = compute_sha256(&canonical);
114    let message_bytes = message_hash.as_bytes();
115
116    verify_signature(message_bytes, sig, verifying_key_bytes)
117}
118
119/// Signs a message with a signing key.
120///
121/// Returns a Signature struct with alg="ed25519" and base64url-encoded signature.
122pub fn sign_message(
123    message: &[u8],
124    signing_key: &SigningKey,
125    kid: String,
126) -> Result<Signature, SignatureError> {
127    let sig = signing_key.sign(message);
128    let sig_bytes = sig.to_bytes();
129
130    // Encode as base64url
131    let sig_base64 = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(sig_bytes);
132
133    Ok(Signature {
134        alg: "ed25519".to_string(),
135        kid,
136        sig: sig_base64,
137    })
138}
139
140/// Signs a span commitment.
141///
142/// This is a convenience function that:
143/// 1. Canonicalizes the commitment
144/// 2. Computes SHA-256 hash
145/// 3. Signs the hash
146pub fn sign_span_commitment(
147    commitment: &SpanCommitment,
148    signing_key: &SigningKey,
149    kid: String,
150) -> Result<Signature, SignatureError> {
151    // Canonicalize and hash
152    let canonical = crate::canonical::canonicalize_span_commitment(commitment)
153        .map_err(|e| SignatureError::Decoding(format!("canonicalization failed: {}", e)))?;
154    let message_hash = compute_sha256(&canonical);
155    let message_bytes = message_hash.as_bytes();
156
157    sign_message(message_bytes, signing_key, kid)
158}
159
160#[cfg(test)]
161mod tests {
162    use super::*;
163    use rand::rngs::OsRng;
164
165    #[test]
166    fn test_validate_signature_format_valid() {
167        let sig = Signature {
168            alg: "ed25519".to_string(),
169            kid: "did:key:zTest".to_string(),
170            sig: base64::engine::general_purpose::URL_SAFE_NO_PAD.encode([0u8; 64]),
171        };
172        assert!(validate_signature_format(&sig).is_ok());
173    }
174
175    #[test]
176    fn test_validate_signature_format_invalid_alg() {
177        let sig = Signature {
178            alg: "rsa".to_string(),
179            kid: "did:key:zTest".to_string(),
180            sig: base64::engine::general_purpose::URL_SAFE_NO_PAD.encode([0u8; 64]),
181        };
182        assert!(validate_signature_format(&sig).is_err());
183    }
184
185    #[test]
186    fn test_sign_and_verify() {
187        let mut csprng = OsRng;
188        let signing_key = SigningKey::generate(&mut csprng);
189        let verifying_key = signing_key.verifying_key();
190
191        let message = b"test message";
192        let kid = "did:key:zTest".to_string();
193
194        // Sign
195        let sig = sign_message(message, &signing_key, kid.clone()).unwrap();
196
197        // Verify
198        let verified = verify_signature(message, &sig, verifying_key.as_bytes()).unwrap();
199        assert!(verified, "Signature should verify");
200
201        // Verify with wrong message
202        let wrong_message = b"wrong message";
203        let verified_wrong =
204            verify_signature(wrong_message, &sig, verifying_key.as_bytes()).unwrap();
205        assert!(
206            !verified_wrong,
207            "Signature should not verify for wrong message"
208        );
209    }
210
211    #[test]
212    fn test_sign_and_verify_span_commitment() {
213        use crate::types::{ArtifactRef, MethodRef, SpanCommitment, SpanStatus};
214
215        let mut csprng = OsRng;
216        let signing_key = SigningKey::generate(&mut csprng);
217        let verifying_key = signing_key.verifying_key();
218        let kid = "did:key:zExecutor".to_string();
219
220        let commitment = SpanCommitment {
221            schema_version: "proof.v2".to_string(),
222            trace_id: "tr_01H1234567890".to_string(),
223            span_id: "sp-0001".to_string(),
224            method_ref: MethodRef {
225                method_id: "com.test/method".to_string(),
226                version: "1.0.0".to_string(),
227                method_shape_root:
228                    "sha256:0000000000000000000000000000000000000000000000000000000000000000"
229                        .to_string(),
230                image_digest:
231                    "sha256:0000000000000000000000000000000000000000000000000000000000000000"
232                        .to_string(),
233                method_fingerprint: "git:deadbeef".to_string(),
234            },
235            data_shape_hash:
236                "sha256:0000000000000000000000000000000000000000000000000000000000000000"
237                    .to_string(),
238            span_shape_hash:
239                "sha256:0000000000000000000000000000000000000000000000000000000000000000"
240                    .to_string(),
241            inputs: vec![ArtifactRef {
242                name: "input.csv".to_string(),
243                content_hash:
244                    "sha256:1111111111111111111111111111111111111111111111111111111111111111"
245                        .to_string(),
246                uri: None,
247            }],
248            outputs: vec![ArtifactRef {
249                name: "output.json".to_string(),
250                content_hash:
251                    "sha256:2222222222222222222222222222222222222222222222222222222222222222"
252                        .to_string(),
253                uri: None,
254            }],
255            start_time: "2025-11-06T12:00:00.000Z".to_string(),
256            end_time: "2025-11-06T12:00:00.042Z".to_string(),
257            status: SpanStatus::OK,
258            meta: None,
259        };
260
261        // Sign
262        let sig = sign_span_commitment(&commitment, &signing_key, kid.clone()).unwrap();
263
264        // Verify
265        let verified =
266            verify_span_commitment_signature(&commitment, &sig, verifying_key.as_bytes()).unwrap();
267        assert!(verified, "Signature should verify");
268    }
269}