Skip to main content

mur_core/constitution/
signing.rs

1//! Ed25519 signing and SHA-256 checksum verification for the constitution.
2//!
3//! The constitution is tamper-proof: any modification to the TOML file will
4//! invalidate both the checksum and the Ed25519 signature. Users must re-sign
5//! the constitution after any intentional modification.
6
7use base64::engine::general_purpose::STANDARD as BASE64;
8use base64::Engine;
9use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
10use sha2::{Digest, Sha256};
11use std::path::Path;
12
13use super::ConstitutionError;
14
15/// An Ed25519 keypair for constitution signing.
16#[derive(Debug)]
17pub struct ConstitutionKeypair {
18    signing_key: SigningKey,
19}
20
21/// Result of signing a constitution file.
22#[derive(Debug, Clone)]
23pub struct SignatureResult {
24    /// SHA-256 hex digest of the constitution content.
25    pub checksum: String,
26    /// Base64-encoded Ed25519 signature of the checksum.
27    pub signature: String,
28    /// Base64-encoded public key for verification.
29    pub public_key: String,
30}
31
32/// Result of verifying a constitution file.
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub enum VerifyResult {
35    /// The constitution is valid and untampered.
36    Valid,
37    /// The checksum doesn't match — file was modified.
38    ChecksumMismatch {
39        expected: String,
40        actual: String,
41    },
42    /// The signature is invalid — possibly tampered.
43    SignatureInvalid,
44    /// Missing required fields (checksum/signature/public_key).
45    MissingFields(String),
46}
47
48impl ConstitutionKeypair {
49    /// Generate a new random Ed25519 keypair.
50    pub fn generate() -> Self {
51        let mut rng = rand::thread_rng();
52        let signing_key = SigningKey::generate(&mut rng);
53        Self { signing_key }
54    }
55
56    /// Load a keypair from a base64-encoded private key.
57    pub fn from_base64(key_b64: &str) -> Result<Self, ConstitutionError> {
58        let bytes = BASE64.decode(key_b64).map_err(|e| {
59            ConstitutionError::SigningError(format!("Invalid base64 key: {}", e))
60        })?;
61        let key_bytes: [u8; 32] = bytes.try_into().map_err(|_| {
62            ConstitutionError::SigningError("Key must be exactly 32 bytes".into())
63        })?;
64        Ok(Self {
65            signing_key: SigningKey::from_bytes(&key_bytes),
66        })
67    }
68
69    /// Export the private key as base64.
70    pub fn private_key_base64(&self) -> String {
71        BASE64.encode(self.signing_key.to_bytes())
72    }
73
74    /// Export the public key as base64.
75    pub fn public_key_base64(&self) -> String {
76        BASE64.encode(self.signing_key.verifying_key().to_bytes())
77    }
78
79    /// Sign a constitution file. Returns the checksum and signature.
80    ///
81    /// The signing process:
82    /// 1. Read the file content
83    /// 2. Extract the signable content (everything except identity checksum/signature fields)
84    /// 3. Compute SHA-256 of the signable content
85    /// 4. Sign the checksum with Ed25519
86    pub fn sign_file(&self, path: &Path) -> Result<SignatureResult, ConstitutionError> {
87        let content = std::fs::read_to_string(path)?;
88        self.sign_content(&content)
89    }
90
91    /// Sign constitution content directly (useful for testing).
92    pub fn sign_content(&self, content: &str) -> Result<SignatureResult, ConstitutionError> {
93        let signable = extract_signable_content(content);
94        let checksum = compute_sha256(&signable);
95        let checksum_bytes = hex::decode(&checksum).map_err(|e| {
96            ConstitutionError::SigningError(format!("Hex decode error: {}", e))
97        })?;
98        let signature = self.signing_key.sign(&checksum_bytes);
99
100        Ok(SignatureResult {
101            checksum,
102            signature: BASE64.encode(signature.to_bytes()),
103            public_key: self.public_key_base64(),
104        })
105    }
106}
107
108/// Verify a constitution file's integrity.
109///
110/// Checks:
111/// 1. The stored checksum matches the actual content hash
112/// 2. The Ed25519 signature is valid for that checksum
113pub fn verify_constitution(
114    content: &str,
115    stored_checksum: &str,
116    stored_signature_b64: &str,
117    public_key_b64: &str,
118) -> VerifyResult {
119    // Check for missing fields
120    if stored_checksum.is_empty() || stored_signature_b64.is_empty() || public_key_b64.is_empty() {
121        return VerifyResult::MissingFields(
122            "Constitution must have checksum, signature, and public key".into(),
123        );
124    }
125
126    // 1. Recompute checksum from file content
127    let signable = extract_signable_content(content);
128    let actual_checksum = compute_sha256(&signable);
129
130    // 2. Compare checksums
131    if actual_checksum != stored_checksum {
132        return VerifyResult::ChecksumMismatch {
133            expected: stored_checksum.to_string(),
134            actual: actual_checksum,
135        };
136    }
137
138    // 3. Verify Ed25519 signature
139    let sig_bytes = match BASE64.decode(stored_signature_b64) {
140        Ok(b) => b,
141        Err(_) => return VerifyResult::SignatureInvalid,
142    };
143
144    let signature = match Signature::from_slice(&sig_bytes) {
145        Ok(s) => s,
146        Err(_) => return VerifyResult::SignatureInvalid,
147    };
148
149    let pk_bytes = match BASE64.decode(public_key_b64) {
150        Ok(b) => b,
151        Err(_) => return VerifyResult::SignatureInvalid,
152    };
153
154    let pk_array: [u8; 32] = match pk_bytes.try_into() {
155        Ok(a) => a,
156        Err(_) => return VerifyResult::SignatureInvalid,
157    };
158
159    let verifying_key = match VerifyingKey::from_bytes(&pk_array) {
160        Ok(k) => k,
161        Err(_) => return VerifyResult::SignatureInvalid,
162    };
163
164    let checksum_bytes = match hex::decode(&actual_checksum) {
165        Ok(b) => b,
166        Err(_) => return VerifyResult::SignatureInvalid,
167    };
168
169    match verifying_key.verify(&checksum_bytes, &signature) {
170        Ok(()) => VerifyResult::Valid,
171        Err(_) => VerifyResult::SignatureInvalid,
172    }
173}
174
175/// Compute SHA-256 hex digest of the given content.
176pub fn compute_sha256(content: &str) -> String {
177    let mut hasher = Sha256::new();
178    hasher.update(content.as_bytes());
179    let result = hasher.finalize();
180    hex::encode(result)
181}
182
183/// Extract the "signable" portion of a constitution TOML.
184///
185/// This removes the mutable identity fields (checksum, signature, signed_by)
186/// so that signing doesn't create a circular dependency. The signable content
187/// is everything in [boundaries], [resource_limits], and [model_permissions].
188fn extract_signable_content(content: &str) -> String {
189    let mut signable_lines = Vec::new();
190    let mut in_identity = false;
191
192    for line in content.lines() {
193        let trimmed = line.trim();
194
195        // Track which section we're in
196        if trimmed.starts_with('[') && !trimmed.starts_with("[[") {
197            in_identity = trimmed == "[identity]";
198        }
199
200        // Skip identity section fields (they change during signing)
201        if in_identity {
202            continue;
203        }
204
205        signable_lines.push(line);
206    }
207
208    signable_lines.join("\n")
209}
210
211/// Hex encoding/decoding helpers (inline to avoid adding another dep).
212mod hex {
213    pub fn encode(bytes: impl AsRef<[u8]>) -> String {
214        bytes
215            .as_ref()
216            .iter()
217            .map(|b| format!("{:02x}", b))
218            .collect()
219    }
220
221    pub fn decode(s: &str) -> Result<Vec<u8>, String> {
222        if !s.len().is_multiple_of(2) {
223            return Err("Hex string must have even length".into());
224        }
225        (0..s.len())
226            .step_by(2)
227            .map(|i| {
228                u8::from_str_radix(&s[i..i + 2], 16)
229                    .map_err(|e| format!("Invalid hex at position {}: {}", i, e))
230            })
231            .collect()
232    }
233}
234
235#[cfg(test)]
236mod tests {
237    use super::*;
238
239    const SAMPLE_CONSTITUTION: &str = r#"
240[identity]
241version = "1.0.0"
242checksum = ""
243signed_by = ""
244signature = ""
245
246[boundaries]
247forbidden = ["rm -rf /", "DROP DATABASE"]
248requires_approval = ["git push", "deploy *"]
249auto_allowed = ["git status", "run tests"]
250
251[resource_limits]
252max_api_cost_per_run = 5.0
253max_api_cost_per_day = 50.0
254max_execution_time = 3600
255max_concurrent_workflows = 3
256max_file_write_size = "10MB"
257allowed_directories = ["~/Projects"]
258blocked_directories = ["/etc"]
259
260[model_permissions]
261thinking_model = { can_execute = false, can_read = true }
262coding_model = { can_execute = true, can_read = true, sandbox_only = true }
263task_model = { can_execute = true, can_read = true }
264"#;
265
266    #[test]
267    fn test_generate_keypair() {
268        let kp = ConstitutionKeypair::generate();
269        let pk = kp.public_key_base64();
270        let sk = kp.private_key_base64();
271        assert!(!pk.is_empty());
272        assert!(!sk.is_empty());
273        // Keys should be different
274        assert_ne!(pk, sk);
275    }
276
277    #[test]
278    fn test_keypair_roundtrip() {
279        let kp = ConstitutionKeypair::generate();
280        let sk_b64 = kp.private_key_base64();
281        let pk_b64 = kp.public_key_base64();
282
283        let kp2 = ConstitutionKeypair::from_base64(&sk_b64).unwrap();
284        assert_eq!(kp2.public_key_base64(), pk_b64);
285    }
286
287    #[test]
288    fn test_sign_and_verify() {
289        let kp = ConstitutionKeypair::generate();
290        let result = kp.sign_content(SAMPLE_CONSTITUTION).unwrap();
291
292        assert!(!result.checksum.is_empty());
293        assert!(!result.signature.is_empty());
294
295        let verify = verify_constitution(
296            SAMPLE_CONSTITUTION,
297            &result.checksum,
298            &result.signature,
299            &result.public_key,
300        );
301        assert_eq!(verify, VerifyResult::Valid);
302    }
303
304    #[test]
305    fn test_tamper_detection_content_modified() {
306        let kp = ConstitutionKeypair::generate();
307        let result = kp.sign_content(SAMPLE_CONSTITUTION).unwrap();
308
309        // Tamper with the content
310        let tampered = SAMPLE_CONSTITUTION.replace("rm -rf /", "rm -rf /home");
311
312        let verify = verify_constitution(
313            &tampered,
314            &result.checksum,
315            &result.signature,
316            &result.public_key,
317        );
318        assert!(matches!(verify, VerifyResult::ChecksumMismatch { .. }));
319    }
320
321    #[test]
322    fn test_tamper_detection_signature_modified() {
323        let kp = ConstitutionKeypair::generate();
324        let result = kp.sign_content(SAMPLE_CONSTITUTION).unwrap();
325
326        // Use a different keypair's signature
327        let kp2 = ConstitutionKeypair::generate();
328        let result2 = kp2.sign_content(SAMPLE_CONSTITUTION).unwrap();
329
330        let verify = verify_constitution(
331            SAMPLE_CONSTITUTION,
332            &result.checksum,
333            &result2.signature, // Wrong signature!
334            &result.public_key,
335        );
336        assert_eq!(verify, VerifyResult::SignatureInvalid);
337    }
338
339    #[test]
340    fn test_tamper_detection_wrong_public_key() {
341        let kp = ConstitutionKeypair::generate();
342        let result = kp.sign_content(SAMPLE_CONSTITUTION).unwrap();
343
344        // Use a different public key
345        let kp2 = ConstitutionKeypair::generate();
346
347        let verify = verify_constitution(
348            SAMPLE_CONSTITUTION,
349            &result.checksum,
350            &result.signature,
351            &kp2.public_key_base64(), // Wrong key!
352        );
353        assert_eq!(verify, VerifyResult::SignatureInvalid);
354    }
355
356    #[test]
357    fn test_missing_fields() {
358        let verify = verify_constitution(SAMPLE_CONSTITUTION, "", "sig", "pk");
359        assert!(matches!(verify, VerifyResult::MissingFields(_)));
360
361        let verify = verify_constitution(SAMPLE_CONSTITUTION, "checksum", "", "pk");
362        assert!(matches!(verify, VerifyResult::MissingFields(_)));
363    }
364
365    #[test]
366    fn test_sha256_deterministic() {
367        let hash1 = compute_sha256("hello world");
368        let hash2 = compute_sha256("hello world");
369        assert_eq!(hash1, hash2);
370        assert_eq!(hash1.len(), 64); // 256 bits = 64 hex chars
371    }
372
373    #[test]
374    fn test_sha256_different_inputs() {
375        let hash1 = compute_sha256("hello");
376        let hash2 = compute_sha256("world");
377        assert_ne!(hash1, hash2);
378    }
379
380    #[test]
381    fn test_extract_signable_content_excludes_identity() {
382        let signable = extract_signable_content(SAMPLE_CONSTITUTION);
383        assert!(!signable.contains("[identity]"));
384        assert!(!signable.contains("checksum"));
385        assert!(!signable.contains("signed_by"));
386        // But should contain other sections
387        assert!(signable.contains("[boundaries]"));
388        assert!(signable.contains("[resource_limits]"));
389    }
390
391    #[test]
392    fn test_identity_changes_dont_affect_checksum() {
393        // Changing identity fields should NOT change the signable content
394        let content1 = SAMPLE_CONSTITUTION;
395        let content2 = SAMPLE_CONSTITUTION.replace(
396            "checksum = \"\"",
397            "checksum = \"abc123\"",
398        );
399
400        let signable1 = extract_signable_content(content1);
401        let signable2 = extract_signable_content(&content2);
402
403        assert_eq!(
404            compute_sha256(&signable1),
405            compute_sha256(&signable2),
406        );
407    }
408
409    #[test]
410    fn test_hex_roundtrip() {
411        let original = vec![0u8, 1, 255, 128, 64];
412        let encoded = hex::encode(&original);
413        let decoded = hex::decode(&encoded).unwrap();
414        assert_eq!(original, decoded);
415    }
416}