Skip to main content

oxihuman_core/
pack_sign.rs

1// Copyright (C) 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3
4//! HMAC-like asset pack signing using SHA-256 double-hash construction.
5//!
6//! Signing scheme: `SHA256(key || SHA256(message))` — length-extension resistant.
7
8#![allow(dead_code)]
9
10use anyhow::{bail, Context, Result};
11use sha2::{Digest, Sha256};
12use std::path::Path;
13
14// ── Public structs ────────────────────────────────────────────────────────────
15
16/// Signature metadata for a signed pack.
17pub struct PackSignature {
18    /// Format version — currently 1.
19    pub version: u32,
20    /// Algorithm identifier, e.g. "sha256-chain".
21    pub algorithm: String,
22    /// Raw 32-byte signature.
23    pub signature: Vec<u8>,
24    /// Unix timestamp in seconds (0 for deterministic tests).
25    pub timestamp: u64,
26    /// Identifies who signed the pack.
27    pub signer_id: String,
28}
29
30/// A manifest hash paired with its signature.
31pub struct SignedPack {
32    /// SHA-256 of sorted manifest content.
33    pub manifest_hash: Vec<u8>,
34    /// The signature covering `manifest_hash`.
35    pub signature: PackSignature,
36}
37
38// ── Core crypto primitive ─────────────────────────────────────────────────────
39
40/// `SHA256(key || SHA256(message))` — length-extension resistant double-hash.
41pub fn double_hash_sign(key: &[u8], message: &[u8]) -> Vec<u8> {
42    // Inner hash: SHA256(message)
43    let mut inner = Sha256::new();
44    inner.update(message);
45    let inner_hash = inner.finalize();
46
47    // Outer hash: SHA256(key || inner_hash)
48    let mut outer = Sha256::new();
49    outer.update(key);
50    outer.update(inner_hash);
51    outer.finalize().to_vec()
52}
53
54// ── Manifest hash ─────────────────────────────────────────────────────────────
55
56/// Compute SHA-256 over sorted `"path:sha256hex"` lines for all files in `dir`.
57pub 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
103// ── Sign / verify ─────────────────────────────────────────────────────────────
104
105/// Sign all files in `dir` and return a `SignedPack`.
106pub 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
122/// Verify a `SignedPack` against the current state of `dir` using `key`.
123pub 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, &current_hash);
132    expected_sig == signed.signature.signature
133}
134
135// ── Hex encoding ──────────────────────────────────────────────────────────────
136
137/// Hex-encode the signature bytes.
138pub fn signature_to_hex(sig: &PackSignature) -> String {
139    hex::encode(&sig.signature)
140}
141
142/// Decode a hex signature string back into a `PackSignature`.
143pub 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
163// ── File I/O ──────────────────────────────────────────────────────────────────
164
165/// Write a `SignedPack` to a text file in simple key=value format.
166pub 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
182/// Read a `SignedPack` from a text file written by `write_signature_file`.
183pub 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// ── Tests ─────────────────────────────────────────────────────────────────────
237
238#[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    // 1. double_hash_sign is deterministic
261    #[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    // 2. double_hash_sign produces 32 bytes
271    #[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    // 3. Different keys produce different signatures
278    #[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    // 4. Different messages produce different signatures
287    #[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    // 5. Empty message is handled
296    #[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    // 6. Empty key is handled
303    #[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    // 7. signature_to_hex / signature_from_hex round-trip
310    #[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    // 8. signature_from_hex rejects bad length
337    #[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    // 9. write_signature_file / read_signature_file round-trip
345    #[test]
346    fn write_read_signature_file_roundtrip() {
347        let tmp = tempdir("roundtrip");
348        let sig_path = tmp.join("sig.txt");
349
350        // Build a signed pack with known data
351        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    // 10. verify_pack_signature succeeds on consistent data
373    #[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    // 11. Wrong key fails verification
385    #[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    // 12. Tampered file fails verification
395    #[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        // Tamper after signing
404        write_file(&tmp, "file.bin", b"tampered!");
405
406        assert!(!verify_pack_signature(&tmp, &signed, key));
407    }
408
409    // 13. pack_manifest_hash is stable
410    #[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}