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#[derive(Debug, Clone)]
14pub struct VerifiedPackage {
15 pub manifest: PackageManifest,
17 pub manifest_raw: Vec<u8>,
19 pub sig_raw: Vec<u8>,
21}
22
23pub 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 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 let manifest_bytes =
53 read_zip_entry(&mut archive, "manifest.toml").map_err(|_| PackError::ManifestNotFound)?;
54
55 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 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 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 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 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 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 let mut tampered = pkg_bytes.clone();
221 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 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 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 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 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 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}