Skip to main content

any_gpu/
nanosign.rs

1// Unlicense — cochranblock.org
2// Contributors: GotEmCoach, KOVA, Claude Opus 4.6
3//
4// NanoSign — 36-byte model file signing. NSIG + BLAKE3 hash.
5// Spec: https://github.com/cochranblock/kova/blob/main/docs/NANOSIGN.md
6
7use anyhow::{Context, Result};
8use std::path::Path;
9
10const MAGIC: &[u8; 4] = b"NSIG";
11const SIG_LEN: usize = 4 + 32; // magic + BLAKE3 hash
12
13#[derive(Debug, PartialEq)]
14pub enum NanoSignResult {
15    Verified(blake3::Hash),
16    Failed { expected: [u8; 32], actual: blake3::Hash },
17    Unsigned,
18}
19
20/// Sign a file: append NSIG + BLAKE3 hash (36 bytes).
21pub fn sign(path: &Path) -> Result<blake3::Hash> {
22    let data = std::fs::read(path).with_context(|| format!("read {}", path.display()))?;
23    let hash = blake3::hash(&data);
24    let mut f = std::fs::OpenOptions::new()
25        .append(true)
26        .open(path)
27        .with_context(|| format!("open for append {}", path.display()))?;
28    std::io::Write::write_all(&mut f, MAGIC)?;
29    std::io::Write::write_all(&mut f, hash.as_bytes())?;
30    Ok(hash)
31}
32
33/// Verify a file's NanoSign signature.
34pub fn verify(path: &Path) -> Result<NanoSignResult> {
35    let data = std::fs::read(path).with_context(|| format!("read {}", path.display()))?;
36    Ok(verify_bytes(&data))
37}
38
39/// Verify NanoSign signature on in-memory bytes.
40pub fn verify_bytes(data: &[u8]) -> NanoSignResult {
41    if data.len() < SIG_LEN {
42        return NanoSignResult::Unsigned;
43    }
44    let (payload, sig) = data.split_at(data.len() - SIG_LEN);
45    if &sig[..4] != MAGIC {
46        return NanoSignResult::Unsigned;
47    }
48    let mut expected = [0u8; 32];
49    expected.copy_from_slice(&sig[4..]);
50    let actual = blake3::hash(payload);
51    if *actual.as_bytes() == expected {
52        NanoSignResult::Verified(actual)
53    } else {
54        NanoSignResult::Failed { expected, actual }
55    }
56}
57
58/// Sign in-memory bytes. Returns payload + 36-byte signature appended.
59pub fn sign_bytes(data: &[u8]) -> Vec<u8> {
60    let hash = blake3::hash(data);
61    let mut out = Vec::with_capacity(data.len() + SIG_LEN);
62    out.extend_from_slice(data);
63    out.extend_from_slice(MAGIC);
64    out.extend_from_slice(hash.as_bytes());
65    out
66}
67
68/// Strip NanoSign signature from in-memory bytes. Returns payload without the 36-byte tail.
69/// If unsigned, returns the data unchanged.
70pub fn strip_bytes(data: &[u8]) -> &[u8] {
71    if data.len() >= SIG_LEN && &data[data.len() - SIG_LEN..data.len() - 32] == MAGIC {
72        &data[..data.len() - SIG_LEN]
73    } else {
74        data
75    }
76}
77
78/// Save model weights with NanoSign. Writes data + NSIG + BLAKE3 hash.
79pub fn save_signed(path: &Path, data: &[u8]) -> Result<blake3::Hash> {
80    let hash = blake3::hash(data);
81    let mut out = Vec::with_capacity(data.len() + SIG_LEN);
82    out.extend_from_slice(data);
83    out.extend_from_slice(MAGIC);
84    out.extend_from_slice(hash.as_bytes());
85    std::fs::write(path, &out).with_context(|| format!("write {}", path.display()))?;
86    Ok(hash)
87}
88
89/// Load model weights with NanoSign verification. Returns payload (without signature).
90/// Fails if signature is present but invalid (tampered). Unsigned files load with a warning.
91pub fn load_verified(path: &Path) -> Result<Vec<u8>> {
92    let data = std::fs::read(path).with_context(|| format!("read {}", path.display()))?;
93    match verify_bytes(&data) {
94        NanoSignResult::Verified(_) => {
95            Ok(data[..data.len() - SIG_LEN].to_vec())
96        }
97        NanoSignResult::Failed { expected, actual } => {
98            anyhow::bail!(
99                "NanoSign FAILED for {}: expected {}, got {} — file tampered or corrupted",
100                path.display(),
101                hex(&expected),
102                actual.to_hex()
103            );
104        }
105        NanoSignResult::Unsigned => {
106            eprintln!("  nanosign: {} is unsigned (no NSIG marker)", path.display());
107            Ok(data)
108        }
109    }
110}
111
112fn hex(bytes: &[u8]) -> String {
113    bytes.iter().map(|b| format!("{b:02x}")).collect()
114}
115
116#[cfg(test)]
117mod tests {
118    use super::*;
119
120    #[test]
121    fn test_sign_and_verify_bytes() {
122        let payload = b"model weights go here";
123        let signed = sign_bytes(payload);
124        assert_eq!(signed.len(), payload.len() + 36);
125        assert_eq!(&signed[signed.len() - 36..signed.len() - 32], b"NSIG");
126        match verify_bytes(&signed) {
127            NanoSignResult::Verified(hash) => {
128                assert_eq!(hash, blake3::hash(payload));
129            }
130            other => panic!("expected Verified, got {other:?}"),
131        }
132    }
133
134    #[test]
135    fn test_unsigned_detection() {
136        let data = b"just some random data without a signature";
137        assert_eq!(verify_bytes(data), NanoSignResult::Unsigned);
138    }
139
140    #[test]
141    fn test_tamper_detection() {
142        let mut signed = sign_bytes(b"original data");
143        // Tamper with the payload
144        signed[0] = b'X';
145        match verify_bytes(&signed) {
146            NanoSignResult::Failed { .. } => {} // expected
147            other => panic!("expected Failed, got {other:?}"),
148        }
149    }
150
151    #[test]
152    fn test_strip_bytes() {
153        let payload = b"model data";
154        let signed = sign_bytes(payload);
155        let stripped = strip_bytes(&signed);
156        assert_eq!(stripped, payload);
157    }
158
159    #[test]
160    fn test_strip_unsigned() {
161        let data = b"no signature here";
162        assert_eq!(strip_bytes(data), data.as_slice());
163    }
164
165    #[test]
166    fn test_too_short() {
167        let data = b"short";
168        assert_eq!(verify_bytes(data), NanoSignResult::Unsigned);
169    }
170
171    #[test]
172    fn test_empty_payload() {
173        let signed = sign_bytes(b"");
174        match verify_bytes(&signed) {
175            NanoSignResult::Verified(hash) => {
176                assert_eq!(hash, blake3::hash(b""));
177            }
178            other => panic!("expected Verified, got {other:?}"),
179        }
180    }
181
182    #[test]
183    fn test_file_roundtrip() {
184        let dir = std::env::temp_dir().join("nanosign_test");
185        std::fs::create_dir_all(&dir).unwrap();
186        let path = dir.join("test_model.weights");
187
188        let payload = vec![42u8; 1024];
189        let hash = save_signed(&path, &payload).unwrap();
190        assert_eq!(hash, blake3::hash(&payload));
191
192        let loaded = load_verified(&path).unwrap();
193        assert_eq!(loaded, payload);
194
195        // Tamper and verify rejection
196        let mut raw = std::fs::read(&path).unwrap();
197        raw[0] = 0xFF;
198        std::fs::write(&path, &raw).unwrap();
199        assert!(load_verified(&path).is_err());
200
201        std::fs::remove_dir_all(&dir).ok();
202    }
203
204    #[test]
205    fn test_sign_bytes_deterministic() {
206        let data = b"same data twice";
207        let s1 = sign_bytes(data);
208        let s2 = sign_bytes(data);
209        assert_eq!(s1, s2);
210    }
211
212    #[test]
213    fn test_verify_exactly_36_bytes() {
214        // 36 bytes that happen to start with NSIG but aren't a valid signature
215        let mut data = vec![0u8; 36];
216        data[0..4].copy_from_slice(b"NSIG");
217        // The "hash" is 32 zero bytes, which won't match blake3::hash(b"")
218        match verify_bytes(&data) {
219            NanoSignResult::Failed { .. } => {} // correct: magic present, hash wrong
220            NanoSignResult::Verified(_) => panic!("should not verify with wrong hash"),
221            NanoSignResult::Unsigned => panic!("should detect NSIG magic"),
222        }
223    }
224
225    #[test]
226    fn test_strip_bytes_no_false_positive() {
227        // Data that contains "NSIG" but not at the right position
228        let mut data = vec![0u8; 100];
229        data[10..14].copy_from_slice(b"NSIG"); // NSIG in the middle, not at -36
230        let stripped = strip_bytes(&data);
231        assert_eq!(stripped.len(), 100); // should not strip
232    }
233
234    #[test]
235    fn test_sign_and_strip_roundtrip() {
236        let payload = b"round trip test data";
237        let signed = sign_bytes(payload);
238        let stripped = strip_bytes(&signed);
239        assert_eq!(stripped, payload);
240    }
241
242    #[test]
243    fn test_load_verified_unsigned_warning() {
244        // Unsigned file should load successfully (with warning to stderr)
245        let dir = std::env::temp_dir().join("nanosign_test_unsigned");
246        std::fs::create_dir_all(&dir).unwrap();
247        let path = dir.join("unsigned.bin");
248        std::fs::write(&path, b"no signature").unwrap();
249        let data = load_verified(&path).unwrap();
250        assert_eq!(data, b"no signature");
251        std::fs::remove_dir_all(&dir).ok();
252    }
253}