1#![allow(dead_code)]
9
10use anyhow::{bail, Context, Result};
11use sha2::{Digest, Sha256};
12use std::path::Path;
13
14pub struct PackSignature {
18 pub version: u32,
20 pub algorithm: String,
22 pub signature: Vec<u8>,
24 pub timestamp: u64,
26 pub signer_id: String,
28}
29
30pub struct SignedPack {
32 pub manifest_hash: Vec<u8>,
34 pub signature: PackSignature,
36}
37
38pub fn double_hash_sign(key: &[u8], message: &[u8]) -> Vec<u8> {
42 let mut inner = Sha256::new();
44 inner.update(message);
45 let inner_hash = inner.finalize();
46
47 let mut outer = Sha256::new();
49 outer.update(key);
50 outer.update(inner_hash);
51 outer.finalize().to_vec()
52}
53
54pub fn pack_manifest_hash(dir: &Path) -> Result<Vec<u8>> {
58 let mut entries: Vec<(String, String)> = Vec::new();
59 collect_manifest_entries(dir, dir, &mut entries)?;
60 entries.sort_by(|a, b| a.0.cmp(&b.0));
61
62 let mut hasher = Sha256::new();
63 for (rel_path, sha_hex) in &entries {
64 let line = format!("{}:{}\n", rel_path, sha_hex);
65 hasher.update(line.as_bytes());
66 }
67 Ok(hasher.finalize().to_vec())
68}
69
70fn collect_manifest_entries(
71 root: &Path,
72 current: &Path,
73 out: &mut Vec<(String, String)>,
74) -> Result<()> {
75 for entry in
76 std::fs::read_dir(current).with_context(|| format!("reading dir {}", current.display()))?
77 {
78 let entry = entry.with_context(|| "dir entry error")?;
79 let path = entry.path();
80 if path.is_dir() {
81 collect_manifest_entries(root, &path, out)?;
82 } else {
83 let data =
84 std::fs::read(&path).with_context(|| format!("reading {}", path.display()))?;
85 let sha_hex = sha256_hex(&data);
86 let rel = path
87 .strip_prefix(root)
88 .with_context(|| "strip prefix")?
89 .to_string_lossy()
90 .replace('\\', "/");
91 out.push((rel, sha_hex));
92 }
93 }
94 Ok(())
95}
96
97fn sha256_hex(data: &[u8]) -> String {
98 let mut h = Sha256::new();
99 h.update(data);
100 hex::encode(h.finalize())
101}
102
103pub fn sign_pack_dir(dir: &Path, key: &[u8], signer_id: &str) -> Result<SignedPack> {
107 let manifest_hash = pack_manifest_hash(dir)?;
108 let signature_bytes = double_hash_sign(key, &manifest_hash);
109
110 Ok(SignedPack {
111 manifest_hash: manifest_hash.clone(),
112 signature: PackSignature {
113 version: 1,
114 algorithm: "sha256-chain".to_string(),
115 signature: signature_bytes,
116 timestamp: 0,
117 signer_id: signer_id.to_string(),
118 },
119 })
120}
121
122pub fn verify_pack_signature(dir: &Path, signed: &SignedPack, key: &[u8]) -> bool {
124 let current_hash = match pack_manifest_hash(dir) {
125 Ok(h) => h,
126 Err(_) => return false,
127 };
128 if current_hash != signed.manifest_hash {
129 return false;
130 }
131 let expected_sig = double_hash_sign(key, ¤t_hash);
132 expected_sig == signed.signature.signature
133}
134
135pub fn signature_to_hex(sig: &PackSignature) -> String {
139 hex::encode(&sig.signature)
140}
141
142pub fn signature_from_hex(
144 hex_str: &str,
145 version: u32,
146 algorithm: &str,
147 timestamp: u64,
148 signer_id: &str,
149) -> Result<PackSignature> {
150 let bytes = hex::decode(hex_str).with_context(|| "hex decode failed")?;
151 if bytes.len() != 32 {
152 bail!("signature must be exactly 32 bytes, got {}", bytes.len());
153 }
154 Ok(PackSignature {
155 version,
156 algorithm: algorithm.to_string(),
157 signature: bytes,
158 timestamp,
159 signer_id: signer_id.to_string(),
160 })
161}
162
163pub fn write_signature_file(signed: &SignedPack, path: &Path) -> Result<()> {
167 let sig = &signed.signature;
168 let content = format!(
169 "version={}\nalgorithm={}\ntimestamp={}\nsigner_id={}\nmanifest_hash={}\nsignature={}\n",
170 sig.version,
171 sig.algorithm,
172 sig.timestamp,
173 sig.signer_id,
174 hex::encode(&signed.manifest_hash),
175 signature_to_hex(sig),
176 );
177 std::fs::write(path, content)
178 .with_context(|| format!("writing signature file {}", path.display()))?;
179 Ok(())
180}
181
182pub fn read_signature_file(path: &Path) -> Result<SignedPack> {
184 let content = std::fs::read_to_string(path)
185 .with_context(|| format!("reading signature file {}", path.display()))?;
186
187 let mut version: Option<u32> = None;
188 let mut algorithm: Option<String> = None;
189 let mut timestamp: Option<u64> = None;
190 let mut signer_id: Option<String> = None;
191 let mut manifest_hash_hex: Option<String> = None;
192 let mut signature_hex: Option<String> = None;
193
194 for line in content.lines() {
195 let line = line.trim();
196 if line.is_empty() {
197 continue;
198 }
199 let (k, v) = line
200 .split_once('=')
201 .with_context(|| format!("malformed line: {}", line))?;
202 match k {
203 "version" => version = Some(v.parse().with_context(|| "parsing version")?),
204 "algorithm" => algorithm = Some(v.to_string()),
205 "timestamp" => timestamp = Some(v.parse().with_context(|| "parsing timestamp")?),
206 "signer_id" => signer_id = Some(v.to_string()),
207 "manifest_hash" => manifest_hash_hex = Some(v.to_string()),
208 "signature" => signature_hex = Some(v.to_string()),
209 _ => bail!("unknown key: {}", k),
210 }
211 }
212
213 let version = version.with_context(|| "missing version")?;
214 let algorithm = algorithm.with_context(|| "missing algorithm")?;
215 let timestamp = timestamp.with_context(|| "missing timestamp")?;
216 let signer_id = signer_id.with_context(|| "missing signer_id")?;
217 let manifest_hash_hex = manifest_hash_hex.with_context(|| "missing manifest_hash")?;
218 let signature_hex = signature_hex.with_context(|| "missing signature")?;
219
220 let manifest_hash =
221 hex::decode(&manifest_hash_hex).with_context(|| "hex decode manifest_hash")?;
222 let signature_bytes = hex::decode(&signature_hex).with_context(|| "hex decode signature")?;
223
224 Ok(SignedPack {
225 manifest_hash,
226 signature: PackSignature {
227 version,
228 algorithm,
229 signature: signature_bytes,
230 timestamp,
231 signer_id,
232 },
233 })
234}
235
236#[cfg(test)]
239mod tests {
240 use super::*;
241 use std::io::Write;
242
243 fn tempdir(suffix: &str) -> std::path::PathBuf {
244 use std::time::{SystemTime, UNIX_EPOCH};
245 let nanos = SystemTime::now()
246 .duration_since(UNIX_EPOCH)
247 .expect("should succeed")
248 .subsec_nanos();
249 let path =
250 std::path::PathBuf::from(format!("/tmp/oxihuman_pack_sign_{}_{}", suffix, nanos));
251 std::fs::create_dir_all(&path).expect("should succeed");
252 path
253 }
254
255 fn write_file(dir: &std::path::Path, name: &str, data: &[u8]) {
256 let mut f = std::fs::File::create(dir.join(name)).expect("should succeed");
257 f.write_all(data).expect("should succeed");
258 }
259
260 #[test]
262 fn double_hash_sign_is_deterministic() {
263 let key = b"secret-key";
264 let msg = b"hello world";
265 let a = double_hash_sign(key, msg);
266 let b = double_hash_sign(key, msg);
267 assert_eq!(a, b);
268 }
269
270 #[test]
272 fn double_hash_sign_is_32_bytes() {
273 let sig = double_hash_sign(b"key", b"msg");
274 assert_eq!(sig.len(), 32);
275 }
276
277 #[test]
279 fn different_keys_produce_different_sigs() {
280 let msg = b"same message";
281 let s1 = double_hash_sign(b"key-one", msg);
282 let s2 = double_hash_sign(b"key-two", msg);
283 assert_ne!(s1, s2);
284 }
285
286 #[test]
288 fn different_messages_produce_different_sigs() {
289 let key = b"same-key";
290 let s1 = double_hash_sign(key, b"message-a");
291 let s2 = double_hash_sign(key, b"message-b");
292 assert_ne!(s1, s2);
293 }
294
295 #[test]
297 fn empty_message_does_not_panic() {
298 let sig = double_hash_sign(b"key", b"");
299 assert_eq!(sig.len(), 32);
300 }
301
302 #[test]
304 fn empty_key_does_not_panic() {
305 let sig = double_hash_sign(b"", b"message");
306 assert_eq!(sig.len(), 32);
307 }
308
309 #[test]
311 fn signature_hex_roundtrip() {
312 let raw = double_hash_sign(b"k", b"m");
313 let sig = PackSignature {
314 version: 1,
315 algorithm: "sha256-chain".to_string(),
316 signature: raw.clone(),
317 timestamp: 42,
318 signer_id: "tester".to_string(),
319 };
320 let hex_str = signature_to_hex(&sig);
321 let recovered = signature_from_hex(
322 &hex_str,
323 sig.version,
324 &sig.algorithm,
325 sig.timestamp,
326 &sig.signer_id,
327 )
328 .expect("should succeed");
329 assert_eq!(recovered.signature, raw);
330 assert_eq!(recovered.version, 1);
331 assert_eq!(recovered.algorithm, "sha256-chain");
332 assert_eq!(recovered.timestamp, 42);
333 assert_eq!(recovered.signer_id, "tester");
334 }
335
336 #[test]
338 fn signature_from_hex_rejects_wrong_length() {
339 let bad_hex = hex::encode(b"tooshort");
340 let result = signature_from_hex(&bad_hex, 1, "sha256-chain", 0, "tester");
341 assert!(result.is_err());
342 }
343
344 #[test]
346 fn write_read_signature_file_roundtrip() {
347 let tmp = tempdir("roundtrip");
348 let sig_path = tmp.join("sig.txt");
349
350 let raw_sig = double_hash_sign(b"roundtrip-key", b"roundtrip-data");
352 let signed = SignedPack {
353 manifest_hash: double_hash_sign(b"", b"manifest"),
354 signature: PackSignature {
355 version: 1,
356 algorithm: "sha256-chain".to_string(),
357 signature: raw_sig.clone(),
358 timestamp: 0,
359 signer_id: "ci-bot".to_string(),
360 },
361 };
362
363 write_signature_file(&signed, &sig_path).expect("should succeed");
364 let recovered = read_signature_file(&sig_path).expect("should succeed");
365
366 assert_eq!(recovered.manifest_hash, signed.manifest_hash);
367 assert_eq!(recovered.signature.signature, raw_sig);
368 assert_eq!(recovered.signature.version, 1);
369 assert_eq!(recovered.signature.signer_id, "ci-bot");
370 }
371
372 #[test]
374 fn verify_pack_signature_succeeds_on_valid_data() {
375 let tmp = tempdir("verify_ok");
376 write_file(&tmp, "a.bin", b"alpha");
377 write_file(&tmp, "b.bin", b"beta");
378
379 let key = b"correct-key";
380 let signed = sign_pack_dir(&tmp, key, "test-signer").expect("should succeed");
381 assert!(verify_pack_signature(&tmp, &signed, key));
382 }
383
384 #[test]
386 fn verify_pack_signature_fails_wrong_key() {
387 let tmp = tempdir("verify_wrong_key");
388 write_file(&tmp, "x.bin", b"data");
389
390 let signed = sign_pack_dir(&tmp, b"correct-key", "signer").expect("should succeed");
391 assert!(!verify_pack_signature(&tmp, &signed, b"wrong-key"));
392 }
393
394 #[test]
396 fn verify_pack_signature_fails_tampered_file() {
397 let tmp = tempdir("verify_tampered");
398 write_file(&tmp, "file.bin", b"original");
399
400 let key = b"tamper-key";
401 let signed = sign_pack_dir(&tmp, key, "signer").expect("should succeed");
402
403 write_file(&tmp, "file.bin", b"tampered!");
405
406 assert!(!verify_pack_signature(&tmp, &signed, key));
407 }
408
409 #[test]
411 fn pack_manifest_hash_is_stable() {
412 let tmp = tempdir("manifest_stable");
413 write_file(&tmp, "c.bin", b"gamma");
414 write_file(&tmp, "a.bin", b"alpha");
415 write_file(&tmp, "b.bin", b"beta");
416
417 let h1 = pack_manifest_hash(&tmp).expect("should succeed");
418 let h2 = pack_manifest_hash(&tmp).expect("should succeed");
419 assert_eq!(h1, h2);
420 }
421}