Skip to main content

clawft_core/security/
signing.rs

1//! Ed25519 skill signing and content hashing.
2//!
3//! Provides key generation, content hashing (SHA-256), digital signature
4//! creation, and verification for skill packages published to ClawHub.
5//!
6//! All operations use Ed25519 (via `ed25519-dalek`) and SHA-256 (via `sha2`).
7//! Keys are stored as hex-encoded files with restricted permissions (0o600).
8
9use std::path::Path;
10
11use ed25519_dalek::{Signer, SigningKey, Verifier, VerifyingKey};
12use serde::{Deserialize, Serialize};
13use sha2::{Digest, Sha256};
14
15use clawft_types::error::ClawftError;
16
17/// SHA-256 content hash over a skill directory.
18#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct SkillContentHash {
20    /// Hex-encoded SHA-256 hash of the concatenated file contents.
21    pub sha256: String,
22    /// Sorted list of file paths included in the hash.
23    pub files: Vec<String>,
24}
25
26/// A digital signature over a content hash.
27#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct SkillSignature {
29    /// Hex-encoded Ed25519 signature.
30    pub signature: String,
31    /// Hex-encoded public key that can verify this signature.
32    pub public_key: String,
33    /// Algorithm identifier (always `"ed25519"`).
34    pub algorithm: String,
35}
36
37/// Name of the private key file.
38const PRIVATE_KEY_FILE: &str = "skill-signing.key";
39/// Name of the public key file.
40const PUBLIC_KEY_FILE: &str = "skill-signing.pub";
41
42/// Generate an Ed25519 key pair and save to `output_dir`.
43///
44/// Creates two files:
45/// - `skill-signing.key` (hex-encoded 32-byte private key, mode 0o600)
46/// - `skill-signing.pub` (hex-encoded 32-byte public key)
47pub fn generate_keypair(output_dir: &Path) -> Result<(), ClawftError> {
48    std::fs::create_dir_all(output_dir)?;
49
50    let mut csprng = rand::thread_rng();
51    let signing_key = SigningKey::generate(&mut csprng);
52    let verifying_key = signing_key.verifying_key();
53
54    let priv_path = output_dir.join(PRIVATE_KEY_FILE);
55    let pub_path = output_dir.join(PUBLIC_KEY_FILE);
56
57    // Write private key as hex.
58    let priv_hex = hex_encode(signing_key.as_bytes());
59    std::fs::write(&priv_path, &priv_hex)?;
60
61    // Set restrictive permissions (Unix only).
62    #[cfg(unix)]
63    {
64        use std::os::unix::fs::PermissionsExt;
65        std::fs::set_permissions(&priv_path, std::fs::Permissions::from_mode(0o600))?;
66    }
67
68    // Write public key as hex.
69    let pub_hex = hex_encode(verifying_key.as_bytes());
70    std::fs::write(&pub_path, &pub_hex)?;
71
72    Ok(())
73}
74
75/// Compute a deterministic SHA-256 content hash over all files in `dir`.
76///
77/// Files are sorted by relative path, then their contents are concatenated
78/// (with a length-prefixed header per file) to produce a single hash. Hidden
79/// files (starting with `.`) and directories named `target` are excluded.
80pub fn compute_content_hash(dir: &Path) -> Result<SkillContentHash, ClawftError> {
81    let mut entries = Vec::new();
82    collect_files(dir, dir, &mut entries)?;
83    entries.sort();
84
85    let mut hasher = Sha256::new();
86    let mut file_list = Vec::new();
87
88    for rel_path in &entries {
89        let abs_path = dir.join(rel_path);
90        let content = std::fs::read(&abs_path)?;
91
92        // Length-prefix each file to prevent boundary confusion.
93        hasher.update(rel_path.as_bytes());
94        hasher.update(b"\0");
95        hasher.update((content.len() as u64).to_le_bytes());
96        hasher.update(&content);
97
98        file_list.push(rel_path.clone());
99    }
100
101    let hash = hasher.finalize();
102    Ok(SkillContentHash {
103        sha256: hex_encode(&hash),
104        files: file_list,
105    })
106}
107
108/// Sign a content hash string with a private key.
109pub fn sign_content(
110    hash: &str,
111    private_key_bytes: &[u8],
112) -> Result<SkillSignature, ClawftError> {
113    let key_bytes: [u8; 32] = private_key_bytes.try_into().map_err(|_| {
114        ClawftError::SecurityViolation {
115            reason: "private key must be exactly 32 bytes".into(),
116        }
117    })?;
118
119    let signing_key = SigningKey::from_bytes(&key_bytes);
120    let verifying_key = signing_key.verifying_key();
121    let signature = signing_key.sign(hash.as_bytes());
122
123    Ok(SkillSignature {
124        signature: hex_encode(&signature.to_bytes()),
125        public_key: hex_encode(verifying_key.as_bytes()),
126        algorithm: "ed25519".into(),
127    })
128}
129
130/// Verify a signature against a content hash.
131pub fn verify_signature(hash: &str, sig: &SkillSignature) -> Result<bool, ClawftError> {
132    if sig.algorithm != "ed25519" {
133        return Err(ClawftError::SecurityViolation {
134            reason: format!("unsupported signature algorithm: {}", sig.algorithm),
135        });
136    }
137
138    let pub_bytes = hex_decode(&sig.public_key).map_err(|e| ClawftError::SecurityViolation {
139        reason: format!("invalid public key hex: {e}"),
140    })?;
141    let sig_bytes = hex_decode(&sig.signature).map_err(|e| ClawftError::SecurityViolation {
142        reason: format!("invalid signature hex: {e}"),
143    })?;
144
145    let pub_array: [u8; 32] = pub_bytes.try_into().map_err(|_| {
146        ClawftError::SecurityViolation {
147            reason: "public key must be exactly 32 bytes".into(),
148        }
149    })?;
150    let sig_array: [u8; 64] = sig_bytes.try_into().map_err(|_| {
151        ClawftError::SecurityViolation {
152            reason: "signature must be exactly 64 bytes".into(),
153        }
154    })?;
155
156    let verifying_key = VerifyingKey::from_bytes(&pub_array).map_err(|e| {
157        ClawftError::SecurityViolation {
158            reason: format!("invalid public key: {e}"),
159        }
160    })?;
161    let signature = ed25519_dalek::Signature::from_bytes(&sig_array);
162
163    Ok(verifying_key.verify(hash.as_bytes(), &signature).is_ok())
164}
165
166/// Load a private signing key from `keys_dir/skill-signing.key`.
167///
168/// Returns `Ok(None)` if the file does not exist.
169pub fn load_signing_key(keys_dir: &Path) -> Result<Option<Vec<u8>>, ClawftError> {
170    let path = keys_dir.join(PRIVATE_KEY_FILE);
171    if !path.exists() {
172        return Ok(None);
173    }
174    let hex_str = std::fs::read_to_string(&path)?;
175    let bytes = hex_decode(hex_str.trim()).map_err(|e| ClawftError::SecurityViolation {
176        reason: format!("invalid private key file: {e}"),
177    })?;
178    Ok(Some(bytes))
179}
180
181/// Load a public verification key from `keys_dir/skill-signing.pub`.
182///
183/// Returns `Ok(None)` if the file does not exist.
184pub fn load_public_key(keys_dir: &Path) -> Result<Option<Vec<u8>>, ClawftError> {
185    let path = keys_dir.join(PUBLIC_KEY_FILE);
186    if !path.exists() {
187        return Ok(None);
188    }
189    let hex_str = std::fs::read_to_string(&path)?;
190    let bytes = hex_decode(hex_str.trim()).map_err(|e| ClawftError::SecurityViolation {
191        reason: format!("invalid public key file: {e}"),
192    })?;
193    Ok(Some(bytes))
194}
195
196// ── Helpers ──────────────────────────────────────────────────────────
197
198/// Hex-encode a byte slice.
199fn hex_encode(bytes: &[u8]) -> String {
200    bytes.iter().map(|b| format!("{b:02x}")).collect()
201}
202
203/// Hex-decode a string into bytes.
204fn hex_decode(s: &str) -> Result<Vec<u8>, String> {
205    if !s.len().is_multiple_of(2) {
206        return Err("odd-length hex string".into());
207    }
208    (0..s.len())
209        .step_by(2)
210        .map(|i| u8::from_str_radix(&s[i..i + 2], 16).map_err(|e| e.to_string()))
211        .collect()
212}
213
214/// Recursively collect relative file paths under `base`, excluding hidden
215/// files and `target/` directories.
216fn collect_files(
217    current: &Path,
218    base: &Path,
219    out: &mut Vec<String>,
220) -> Result<(), ClawftError> {
221    if !current.is_dir() {
222        return Ok(());
223    }
224    for entry in std::fs::read_dir(current)? {
225        let entry = entry?;
226        let name = entry.file_name();
227        let name_str = name.to_string_lossy();
228
229        // Skip hidden files and target directories.
230        if name_str.starts_with('.') || name_str == "target" {
231            continue;
232        }
233
234        let path = entry.path();
235        if path.is_dir() {
236            collect_files(&path, base, out)?;
237        } else if path.is_file() {
238            let rel = path
239                .strip_prefix(base)
240                .unwrap_or(&path)
241                .to_string_lossy()
242                .to_string();
243            out.push(rel);
244        }
245    }
246    Ok(())
247}
248
249// ── Tests ────────────────────────────────────────────────────────────
250
251#[cfg(all(test, feature = "signing"))]
252mod tests {
253    use super::*;
254    use std::sync::atomic::{AtomicU64, Ordering};
255
256    static COUNTER: AtomicU64 = AtomicU64::new(0);
257
258    fn temp_dir(prefix: &str) -> std::path::PathBuf {
259        let id = COUNTER.fetch_add(1, Ordering::Relaxed);
260        let pid = std::process::id();
261        std::env::temp_dir().join(format!("clawft_signing_{prefix}_{pid}_{id}"))
262    }
263
264    #[test]
265    fn keygen_creates_keypair() {
266        let dir = temp_dir("keygen");
267        generate_keypair(&dir).unwrap();
268
269        let priv_path = dir.join(PRIVATE_KEY_FILE);
270        let pub_path = dir.join(PUBLIC_KEY_FILE);
271
272        assert!(priv_path.exists(), "private key file should exist");
273        assert!(pub_path.exists(), "public key file should exist");
274
275        // Private key should be 64 hex chars (32 bytes).
276        let priv_hex = std::fs::read_to_string(&priv_path).unwrap();
277        assert_eq!(priv_hex.len(), 64);
278
279        // Public key should be 64 hex chars (32 bytes).
280        let pub_hex = std::fs::read_to_string(&pub_path).unwrap();
281        assert_eq!(pub_hex.len(), 64);
282
283        // Verify permissions on Unix.
284        #[cfg(unix)]
285        {
286            use std::os::unix::fs::PermissionsExt;
287            let perms = std::fs::metadata(&priv_path).unwrap().permissions();
288            assert_eq!(perms.mode() & 0o777, 0o600);
289        }
290
291        let _ = std::fs::remove_dir_all(&dir);
292    }
293
294    #[test]
295    fn content_hash_deterministic() {
296        let dir = temp_dir("hash_det");
297        std::fs::create_dir_all(&dir).unwrap();
298        std::fs::write(dir.join("SKILL.md"), "---\nname: test\n---\nInstructions.").unwrap();
299        std::fs::write(dir.join("extra.txt"), "extra content").unwrap();
300
301        let hash1 = compute_content_hash(&dir).unwrap();
302        let hash2 = compute_content_hash(&dir).unwrap();
303
304        assert_eq!(hash1.sha256, hash2.sha256);
305        assert_eq!(hash1.files, hash2.files);
306
307        let _ = std::fs::remove_dir_all(&dir);
308    }
309
310    #[test]
311    fn content_hash_detects_changes() {
312        let dir = temp_dir("hash_change");
313        std::fs::create_dir_all(&dir).unwrap();
314        std::fs::write(dir.join("SKILL.md"), "original content").unwrap();
315
316        let hash_before = compute_content_hash(&dir).unwrap();
317
318        std::fs::write(dir.join("SKILL.md"), "modified content").unwrap();
319        let hash_after = compute_content_hash(&dir).unwrap();
320
321        assert_ne!(hash_before.sha256, hash_after.sha256);
322
323        let _ = std::fs::remove_dir_all(&dir);
324    }
325
326    #[test]
327    fn sign_and_verify_roundtrip() {
328        let dir = temp_dir("sign_roundtrip");
329        generate_keypair(&dir).unwrap();
330
331        let priv_key = load_signing_key(&dir).unwrap().unwrap();
332        let hash = "abc123def456";
333        let sig = sign_content(hash, &priv_key).unwrap();
334
335        assert_eq!(sig.algorithm, "ed25519");
336        assert!(verify_signature(hash, &sig).unwrap());
337
338        let _ = std::fs::remove_dir_all(&dir);
339    }
340
341    #[test]
342    fn verify_wrong_key_fails() {
343        let dir1 = temp_dir("wrong_key_1");
344        let dir2 = temp_dir("wrong_key_2");
345
346        generate_keypair(&dir1).unwrap();
347        generate_keypair(&dir2).unwrap();
348
349        let priv_key1 = load_signing_key(&dir1).unwrap().unwrap();
350        let hash = "test_hash_value";
351        let mut sig = sign_content(hash, &priv_key1).unwrap();
352
353        // Replace the public key with the one from a different key pair.
354        let pub_key2 = load_public_key(&dir2).unwrap().unwrap();
355        sig.public_key = hex_encode(&pub_key2);
356
357        assert!(!verify_signature(hash, &sig).unwrap());
358
359        let _ = std::fs::remove_dir_all(&dir1);
360        let _ = std::fs::remove_dir_all(&dir2);
361    }
362
363    #[test]
364    fn verify_tampered_hash_fails() {
365        let dir = temp_dir("tampered");
366        generate_keypair(&dir).unwrap();
367
368        let priv_key = load_signing_key(&dir).unwrap().unwrap();
369        let hash = "original_hash";
370        let sig = sign_content(hash, &priv_key).unwrap();
371
372        // Verify with a different hash should fail.
373        assert!(!verify_signature("tampered_hash", &sig).unwrap());
374
375        let _ = std::fs::remove_dir_all(&dir);
376    }
377
378    #[test]
379    fn load_missing_key_returns_none() {
380        let dir = temp_dir("no_key");
381        std::fs::create_dir_all(&dir).unwrap();
382
383        assert!(load_signing_key(&dir).unwrap().is_none());
384        assert!(load_public_key(&dir).unwrap().is_none());
385
386        let _ = std::fs::remove_dir_all(&dir);
387    }
388
389    #[test]
390    fn hex_roundtrip() {
391        let data = [0xDE, 0xAD, 0xBE, 0xEF, 0x00, 0xFF];
392        let encoded = hex_encode(&data);
393        assert_eq!(encoded, "deadbeef00ff");
394        let decoded = hex_decode(&encoded).unwrap();
395        assert_eq!(decoded, data);
396    }
397}