clawft_core/security/
signing.rs1use 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#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct SkillContentHash {
20 pub sha256: String,
22 pub files: Vec<String>,
24}
25
26#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct SkillSignature {
29 pub signature: String,
31 pub public_key: String,
33 pub algorithm: String,
35}
36
37const PRIVATE_KEY_FILE: &str = "skill-signing.key";
39const PUBLIC_KEY_FILE: &str = "skill-signing.pub";
41
42pub 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 let priv_hex = hex_encode(signing_key.as_bytes());
59 std::fs::write(&priv_path, &priv_hex)?;
60
61 #[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 let pub_hex = hex_encode(verifying_key.as_bytes());
70 std::fs::write(&pub_path, &pub_hex)?;
71
72 Ok(())
73}
74
75pub 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 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
108pub 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
130pub 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
166pub 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
181pub 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
196fn hex_encode(bytes: &[u8]) -> String {
200 bytes.iter().map(|b| format!("{b:02x}")).collect()
201}
202
203fn 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
214fn 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 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#[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 let priv_hex = std::fs::read_to_string(&priv_path).unwrap();
277 assert_eq!(priv_hex.len(), 64);
278
279 let pub_hex = std::fs::read_to_string(&pub_path).unwrap();
281 assert_eq!(pub_hex.len(), 64);
282
283 #[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 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 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}