Skip to main content

actr_pack/
verify.rs

1use std::io::Cursor;
2
3use ed25519_dalek::{Signature, VerifyingKey};
4
5use crate::error::PackError;
6use crate::manifest::PackageManifest;
7use crate::util::{read_zip_entry, sha256_hex};
8
9/// Result of a successful package verification.
10///
11/// Contains the parsed manifest along with the raw bytes needed for
12/// transparent forwarding to AIS for signature verification.
13#[derive(Debug, Clone)]
14pub struct VerifiedPackage {
15    /// Parsed package manifest.
16    pub manifest: PackageManifest,
17    /// Raw `manifest.toml` bytes as stored in the ZIP (the signed payload).
18    pub manifest_raw: Vec<u8>,
19    /// Raw `manifest.sig` bytes (64-byte Ed25519 signature).
20    pub sig_raw: Vec<u8>,
21}
22
23/// Verify an .actr package.
24///
25/// Verification flow:
26/// 1. Read manifest.sig (64 bytes raw Ed25519 signature)
27/// 2. Read manifest.toml (raw bytes)
28/// 3. Verify Ed25519 signature over manifest.toml bytes
29/// 4. Parse manifest.toml -> PackageManifest
30/// 5. Read binary, verify SHA-256 matches manifest.binary.hash
31/// 6. For each resource, verify SHA-256 matches entry hash
32/// 7. For each proto file, verify SHA-256 matches entry hash
33/// 8. For the optional packaged lock file, verify SHA-256 matches manifest.lock_file.hash
34/// 9. Return VerifiedPackage with manifest + raw bytes
35pub fn verify(actr_bytes: &[u8], pubkey: &VerifyingKey) -> Result<VerifiedPackage, PackError> {
36    let cursor = Cursor::new(actr_bytes);
37    let mut archive = zip::ZipArchive::new(cursor)?;
38
39    // 1. Read manifest.sig
40    let sig_raw =
41        read_zip_entry(&mut archive, "manifest.sig").map_err(|_| PackError::SignatureNotFound)?;
42    if sig_raw.len() != 64 {
43        return Err(PackError::SignatureVerificationFailed(format!(
44            "manifest.sig must be exactly 64 bytes, got {}",
45            sig_raw.len()
46        )));
47    }
48    let sig_arr: [u8; 64] = sig_raw.clone().try_into().unwrap();
49    let signature = Signature::from_bytes(&sig_arr);
50
51    // 2. Read manifest.toml
52    let manifest_bytes =
53        read_zip_entry(&mut archive, "manifest.toml").map_err(|_| PackError::ManifestNotFound)?;
54
55    // 3. Verify signature over manifest.toml
56    pubkey
57        .verify_strict(&manifest_bytes, &signature)
58        .map_err(|e| {
59            PackError::SignatureVerificationFailed(format!("Ed25519 verification failed: {e}"))
60        })?;
61
62    tracing::debug!("package signature verified");
63
64    // 4. Parse manifest
65    let manifest_str = std::str::from_utf8(&manifest_bytes)
66        .map_err(|e| PackError::ManifestParseError(format!("manifest is not valid UTF-8: {e}")))?;
67    let manifest = PackageManifest::from_toml(manifest_str)?;
68
69    // 5. Verify binary hash
70    let binary_bytes = read_zip_entry(&mut archive, &manifest.binary.path)
71        .map_err(|_| PackError::BinaryNotFound(manifest.binary.path.clone()))?;
72    let computed_hash = sha256_hex(&binary_bytes);
73    if computed_hash != manifest.binary.hash {
74        tracing::warn!(
75            expected = %manifest.binary.hash,
76            computed = %computed_hash,
77            path = %manifest.binary.path,
78            "binary hash mismatch"
79        );
80        return Err(PackError::BinaryHashMismatch {
81            path: manifest.binary.path.clone(),
82        });
83    }
84
85    // 6. Verify resource hashes
86    for resource in &manifest.resources {
87        let res_bytes = read_zip_entry(&mut archive, &resource.path)
88            .map_err(|_| PackError::BinaryNotFound(resource.path.clone()))?;
89        let computed = sha256_hex(&res_bytes);
90        if computed != resource.hash {
91            tracing::warn!(
92                expected = %resource.hash,
93                computed = %computed,
94                path = %resource.path,
95                "resource hash mismatch"
96            );
97            return Err(PackError::ResourceHashMismatch {
98                path: resource.path.clone(),
99            });
100        }
101    }
102    // 7. Verify proto file hashes
103    for proto in &manifest.proto_files {
104        let proto_bytes = read_zip_entry(&mut archive, &proto.path)
105            .map_err(|_| PackError::BinaryNotFound(proto.path.clone()))?;
106        let computed = sha256_hex(&proto_bytes);
107        if computed != proto.hash {
108            tracing::warn!(
109                expected = %proto.hash,
110                computed = %computed,
111                path = %proto.path,
112                "proto file hash mismatch"
113            );
114            return Err(PackError::ProtoHashMismatch {
115                path: proto.path.clone(),
116            });
117        }
118    }
119
120    // 8. Verify packaged manifest.lock.toml hash when present
121    if let Some(lock_file) = &manifest.lock_file {
122        let lock_bytes = read_zip_entry(&mut archive, &lock_file.path)
123            .map_err(|_| PackError::BinaryNotFound(lock_file.path.clone()))?;
124        let computed = sha256_hex(&lock_bytes);
125        if computed != lock_file.hash {
126            tracing::warn!(
127                expected = %lock_file.hash,
128                computed = %computed,
129                path = %lock_file.path,
130                "manifest lock hash mismatch"
131            );
132            return Err(PackError::LockFileHashMismatch {
133                path: lock_file.path.clone(),
134            });
135        }
136    }
137
138    tracing::info!(
139        actr_type = %manifest.actr_type_str(),
140        "package verification passed"
141    );
142
143    Ok(VerifiedPackage {
144        manifest,
145        manifest_raw: manifest_bytes,
146        sig_raw,
147    })
148}
149
150#[cfg(test)]
151mod tests {
152    use super::*;
153    use crate::manifest::{BinaryEntry, ManifestMetadata, PackageManifest, ResourceEntry};
154    use crate::pack::{PackOptions, pack};
155    use ed25519_dalek::SigningKey;
156    use rand::rngs::OsRng;
157    use std::io::Write;
158
159    fn test_manifest() -> PackageManifest {
160        PackageManifest {
161            manufacturer: "test-mfr".to_string(),
162            name: "TestActor".to_string(),
163            version: "1.0.0".to_string(),
164            binary: BinaryEntry {
165                path: "bin/actor.wasm".to_string(),
166                target: "wasm32-wasip1".to_string(),
167                hash: String::new(),
168                size: None,
169                kind: None,
170            },
171            signature_algorithm: "ed25519".to_string(),
172            signing_key_id: None,
173            resources: vec![],
174            proto_files: vec![],
175            lock_file: None,
176            metadata: ManifestMetadata::default(),
177        }
178    }
179
180    fn make_package(
181        signing_key: &SigningKey,
182        binary: &[u8],
183        resources: Vec<(String, Vec<u8>)>,
184    ) -> Vec<u8> {
185        let mut manifest = test_manifest();
186        manifest.resources = resources
187            .iter()
188            .map(|(path, _)| ResourceEntry {
189                path: path.clone(),
190                hash: String::new(),
191            })
192            .collect();
193        let opts = PackOptions {
194            manifest,
195            binary_bytes: binary.to_vec(),
196            resources,
197            proto_files: vec![],
198            signing_key: signing_key.clone(),
199            lock_file: None,
200        };
201        pack(&opts).unwrap()
202    }
203
204    #[test]
205    fn roundtrip_succeeds() {
206        let key = SigningKey::generate(&mut OsRng);
207        let pkg = make_package(&key, b"wasm bytes", vec![]);
208        let result = verify(&pkg, &key.verifying_key()).unwrap();
209        assert_eq!(result.manifest.manufacturer, "test-mfr");
210        assert_eq!(result.manifest.name, "TestActor");
211        assert_eq!(result.sig_raw.len(), 64);
212        assert!(!result.manifest_raw.is_empty());
213    }
214
215    #[test]
216    fn tampered_binary_detected() {
217        let key = SigningKey::generate(&mut OsRng);
218        let pkg_bytes = make_package(&key, b"original", vec![]);
219        // Modify a byte deep in the file (in the binary data area)
220        let mut tampered = pkg_bytes.clone();
221        // Find "original" in the ZIP and change it
222        if let Some(pos) = tampered.windows(8).position(|w| w == b"original") {
223            tampered[pos] ^= 0xFF;
224        }
225        let result = verify(&tampered, &key.verifying_key());
226        // Should fail with either signature or hash mismatch
227        assert!(
228            result.is_err(),
229            "tampered package should fail: {:?}",
230            result
231        );
232    }
233
234    #[test]
235    fn wrong_key_rejected() {
236        let key1 = SigningKey::generate(&mut OsRng);
237        let key2 = SigningKey::generate(&mut OsRng);
238        let pkg = make_package(&key1, b"wasm", vec![]);
239        let result = verify(&pkg, &key2.verifying_key());
240        assert!(matches!(
241            result,
242            Err(PackError::SignatureVerificationFailed(_))
243        ));
244    }
245
246    #[test]
247    fn missing_signature_detected() {
248        // Create a ZIP without manifest.sig
249        let cursor = std::io::Cursor::new(Vec::new());
250        let mut zip = zip::ZipWriter::new(cursor);
251        let opts = zip::write::SimpleFileOptions::default()
252            .compression_method(zip::CompressionMethod::Stored);
253        zip.start_file("manifest.toml", opts).unwrap();
254        zip.write_all(b"[fake]").unwrap();
255        let data = zip.finish().unwrap().into_inner();
256
257        let key = SigningKey::generate(&mut OsRng);
258        let result = verify(&data, &key.verifying_key());
259        assert!(matches!(result, Err(PackError::SignatureNotFound)));
260    }
261
262    #[test]
263    fn resource_hash_mismatch_detected() {
264        let key = SigningKey::generate(&mut OsRng);
265        let pkg = make_package(
266            &key,
267            b"wasm",
268            vec![(
269                "config/settings.toml".to_string(),
270                b"key = \"value\"".to_vec(),
271            )],
272        );
273        // Tamper the resource
274        let mut tampered = pkg.clone();
275        if let Some(pos) = tampered.windows(5).position(|w| w == b"value") {
276            tampered[pos] ^= 0xFF;
277        }
278        let result = verify(&tampered, &key.verifying_key());
279        assert!(result.is_err());
280    }
281
282    #[test]
283    fn with_resources_roundtrip() {
284        let key = SigningKey::generate(&mut OsRng);
285        let resources = vec![
286            ("config/a.toml".to_string(), b"data_a".to_vec()),
287            ("config/b.toml".to_string(), b"data_b".to_vec()),
288        ];
289        let pkg = make_package(&key, b"wasm", resources);
290        let result = verify(&pkg, &key.verifying_key()).unwrap();
291        assert_eq!(result.manifest.resources.len(), 2);
292    }
293
294    fn make_package_with_protos(
295        signing_key: &SigningKey,
296        binary: &[u8],
297        protos: Vec<(String, Vec<u8>)>,
298    ) -> Vec<u8> {
299        use crate::manifest::ProtoFileEntry;
300        let manifest = test_manifest();
301        let proto_entries: Vec<ProtoFileEntry> = protos
302            .iter()
303            .map(|(name, _)| ProtoFileEntry {
304                name: name.clone(),
305                path: format!("proto/{name}"),
306                hash: String::new(),
307            })
308            .collect();
309        let mut m = manifest;
310        m.proto_files = proto_entries;
311        // PackOptions.proto_files uses (name, data) where name is the raw filename
312        // pack() internally creates path as "proto/{name}" and writes to ZIP at that path
313        let opts = PackOptions {
314            manifest: m,
315            binary_bytes: binary.to_vec(),
316            resources: vec![],
317            proto_files: protos,
318            signing_key: signing_key.clone(),
319            lock_file: None,
320        };
321        pack(&opts).unwrap()
322    }
323
324    #[test]
325    fn with_proto_files_roundtrip() {
326        let key = SigningKey::generate(&mut OsRng);
327        let protos = vec![
328            (
329                "echo.proto".to_string(),
330                b"syntax = \"proto3\";\nservice Echo {}".to_vec(),
331            ),
332            (
333                "common.proto".to_string(),
334                b"syntax = \"proto3\";\nmessage Empty {}".to_vec(),
335            ),
336        ];
337        let pkg = make_package_with_protos(&key, b"wasm", protos);
338        let result = verify(&pkg, &key.verifying_key()).unwrap();
339        assert_eq!(result.manifest.proto_files.len(), 2);
340        assert_eq!(result.manifest.proto_files[0].name, "echo.proto");
341        assert_eq!(result.manifest.proto_files[1].name, "common.proto");
342    }
343
344    #[test]
345    fn tampered_proto_detected() {
346        let key = SigningKey::generate(&mut OsRng);
347        let protos = vec![(
348            "echo.proto".to_string(),
349            b"syntax = \"proto3\";\nservice Echo {}".to_vec(),
350        )];
351        let pkg = make_package_with_protos(&key, b"wasm", protos);
352        // Tamper the proto content in the ZIP
353        let mut tampered = pkg.clone();
354        let needle = b"Echo";
355        if let Some(pos) = tampered.windows(needle.len()).position(|w| w == needle) {
356            tampered[pos] ^= 0xFF;
357        }
358        let result = verify(&tampered, &key.verifying_key());
359        assert!(result.is_err(), "tampered proto should fail: {:?}", result);
360    }
361}