actr-pack 0.3.1

ACTR package format: ZIP STORE-based .actr file reading, writing, signing and verification
Documentation
use std::io::Cursor;

use ed25519_dalek::{Signature, VerifyingKey};

use crate::error::PackError;
use crate::manifest::PackageManifest;
use crate::util::{read_zip_entry, sha256_hex};

/// Result of a successful package verification.
///
/// Contains the parsed manifest along with the raw bytes needed for
/// transparent forwarding to AIS for signature verification.
#[derive(Debug, Clone)]
pub struct VerifiedPackage {
    /// Parsed package manifest.
    pub manifest: PackageManifest,
    /// Raw `manifest.toml` bytes as stored in the ZIP (the signed payload).
    pub manifest_raw: Vec<u8>,
    /// Raw `manifest.sig` bytes (64-byte Ed25519 signature).
    pub sig_raw: Vec<u8>,
}

/// Verify an .actr package.
///
/// Verification flow:
/// 1. Read manifest.sig (64 bytes raw Ed25519 signature)
/// 2. Read manifest.toml (raw bytes)
/// 3. Verify Ed25519 signature over manifest.toml bytes
/// 4. Parse manifest.toml -> PackageManifest
/// 5. Read binary, verify SHA-256 matches manifest.binary.hash
/// 6. For each resource, verify SHA-256 matches entry hash
/// 7. For each proto file, verify SHA-256 matches entry hash
/// 8. For the optional packaged lock file, verify SHA-256 matches manifest.lock_file.hash
/// 9. Return VerifiedPackage with manifest + raw bytes
pub fn verify(actr_bytes: &[u8], pubkey: &VerifyingKey) -> Result<VerifiedPackage, PackError> {
    let cursor = Cursor::new(actr_bytes);
    let mut archive = zip::ZipArchive::new(cursor)?;

    // 1. Read manifest.sig
    let sig_raw =
        read_zip_entry(&mut archive, "manifest.sig").map_err(|_| PackError::SignatureNotFound)?;
    if sig_raw.len() != 64 {
        return Err(PackError::SignatureVerificationFailed(format!(
            "manifest.sig must be exactly 64 bytes, got {}",
            sig_raw.len()
        )));
    }
    let sig_arr: [u8; 64] = sig_raw.clone().try_into().unwrap();
    let signature = Signature::from_bytes(&sig_arr);

    // 2. Read manifest.toml
    let manifest_bytes =
        read_zip_entry(&mut archive, "manifest.toml").map_err(|_| PackError::ManifestNotFound)?;

    // 3. Verify signature over manifest.toml
    pubkey
        .verify_strict(&manifest_bytes, &signature)
        .map_err(|e| {
            PackError::SignatureVerificationFailed(format!("Ed25519 verification failed: {e}"))
        })?;

    tracing::debug!("package signature verified");

    // 4. Parse manifest
    let manifest_str = std::str::from_utf8(&manifest_bytes)
        .map_err(|e| PackError::ManifestParseError(format!("manifest is not valid UTF-8: {e}")))?;
    let manifest = PackageManifest::from_toml(manifest_str)?;

    // 5. Verify binary hash
    let binary_bytes = read_zip_entry(&mut archive, &manifest.binary.path)
        .map_err(|_| PackError::BinaryNotFound(manifest.binary.path.clone()))?;
    let computed_hash = sha256_hex(&binary_bytes);
    if computed_hash != manifest.binary.hash {
        tracing::warn!(
            expected = %manifest.binary.hash,
            computed = %computed_hash,
            path = %manifest.binary.path,
            "binary hash mismatch"
        );
        return Err(PackError::BinaryHashMismatch {
            path: manifest.binary.path.clone(),
        });
    }

    // 6. Verify resource hashes
    for resource in &manifest.resources {
        let res_bytes = read_zip_entry(&mut archive, &resource.path)
            .map_err(|_| PackError::BinaryNotFound(resource.path.clone()))?;
        let computed = sha256_hex(&res_bytes);
        if computed != resource.hash {
            tracing::warn!(
                expected = %resource.hash,
                computed = %computed,
                path = %resource.path,
                "resource hash mismatch"
            );
            return Err(PackError::ResourceHashMismatch {
                path: resource.path.clone(),
            });
        }
    }
    // 7. Verify proto file hashes
    for proto in &manifest.proto_files {
        let proto_bytes = read_zip_entry(&mut archive, &proto.path)
            .map_err(|_| PackError::BinaryNotFound(proto.path.clone()))?;
        let computed = sha256_hex(&proto_bytes);
        if computed != proto.hash {
            tracing::warn!(
                expected = %proto.hash,
                computed = %computed,
                path = %proto.path,
                "proto file hash mismatch"
            );
            return Err(PackError::ProtoHashMismatch {
                path: proto.path.clone(),
            });
        }
    }

    // 8. Verify packaged manifest.lock.toml hash when present
    if let Some(lock_file) = &manifest.lock_file {
        let lock_bytes = read_zip_entry(&mut archive, &lock_file.path)
            .map_err(|_| PackError::BinaryNotFound(lock_file.path.clone()))?;
        let computed = sha256_hex(&lock_bytes);
        if computed != lock_file.hash {
            tracing::warn!(
                expected = %lock_file.hash,
                computed = %computed,
                path = %lock_file.path,
                "manifest lock hash mismatch"
            );
            return Err(PackError::LockFileHashMismatch {
                path: lock_file.path.clone(),
            });
        }
    }

    tracing::info!(
        actr_type = %manifest.actr_type_str(),
        "package verification passed"
    );

    Ok(VerifiedPackage {
        manifest,
        manifest_raw: manifest_bytes,
        sig_raw,
    })
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::manifest::{BinaryEntry, ManifestMetadata, PackageManifest, ResourceEntry};
    use crate::pack::{PackOptions, pack};
    use ed25519_dalek::SigningKey;
    use rand::rngs::OsRng;
    use std::io::Write;

    fn test_manifest() -> PackageManifest {
        PackageManifest {
            manufacturer: "test-mfr".to_string(),
            name: "TestActor".to_string(),
            version: "1.0.0".to_string(),
            binary: BinaryEntry {
                path: "bin/actor.wasm".to_string(),
                target: "wasm32-wasip1".to_string(),
                hash: String::new(),
                size: None,
                kind: None,
            },
            signature_algorithm: "ed25519".to_string(),
            signing_key_id: None,
            resources: vec![],
            proto_files: vec![],
            lock_file: None,
            metadata: ManifestMetadata::default(),
        }
    }

    fn make_package(
        signing_key: &SigningKey,
        binary: &[u8],
        resources: Vec<(String, Vec<u8>)>,
    ) -> Vec<u8> {
        let mut manifest = test_manifest();
        manifest.resources = resources
            .iter()
            .map(|(path, _)| ResourceEntry {
                path: path.clone(),
                hash: String::new(),
            })
            .collect();
        let opts = PackOptions {
            manifest,
            binary_bytes: binary.to_vec(),
            resources,
            proto_files: vec![],
            signing_key: signing_key.clone(),
            lock_file: None,
        };
        pack(&opts).unwrap()
    }

    #[test]
    fn roundtrip_succeeds() {
        let key = SigningKey::generate(&mut OsRng);
        let pkg = make_package(&key, b"wasm bytes", vec![]);
        let result = verify(&pkg, &key.verifying_key()).unwrap();
        assert_eq!(result.manifest.manufacturer, "test-mfr");
        assert_eq!(result.manifest.name, "TestActor");
        assert_eq!(result.sig_raw.len(), 64);
        assert!(!result.manifest_raw.is_empty());
    }

    #[test]
    fn tampered_binary_detected() {
        let key = SigningKey::generate(&mut OsRng);
        let pkg_bytes = make_package(&key, b"original", vec![]);
        // Modify a byte deep in the file (in the binary data area)
        let mut tampered = pkg_bytes.clone();
        // Find "original" in the ZIP and change it
        if let Some(pos) = tampered.windows(8).position(|w| w == b"original") {
            tampered[pos] ^= 0xFF;
        }
        let result = verify(&tampered, &key.verifying_key());
        // Should fail with either signature or hash mismatch
        assert!(
            result.is_err(),
            "tampered package should fail: {:?}",
            result
        );
    }

    #[test]
    fn wrong_key_rejected() {
        let key1 = SigningKey::generate(&mut OsRng);
        let key2 = SigningKey::generate(&mut OsRng);
        let pkg = make_package(&key1, b"wasm", vec![]);
        let result = verify(&pkg, &key2.verifying_key());
        assert!(matches!(
            result,
            Err(PackError::SignatureVerificationFailed(_))
        ));
    }

    #[test]
    fn missing_signature_detected() {
        // Create a ZIP without manifest.sig
        let cursor = std::io::Cursor::new(Vec::new());
        let mut zip = zip::ZipWriter::new(cursor);
        let opts = zip::write::SimpleFileOptions::default()
            .compression_method(zip::CompressionMethod::Stored);
        zip.start_file("manifest.toml", opts).unwrap();
        zip.write_all(b"[fake]").unwrap();
        let data = zip.finish().unwrap().into_inner();

        let key = SigningKey::generate(&mut OsRng);
        let result = verify(&data, &key.verifying_key());
        assert!(matches!(result, Err(PackError::SignatureNotFound)));
    }

    #[test]
    fn resource_hash_mismatch_detected() {
        let key = SigningKey::generate(&mut OsRng);
        let pkg = make_package(
            &key,
            b"wasm",
            vec![(
                "config/settings.toml".to_string(),
                b"key = \"value\"".to_vec(),
            )],
        );
        // Tamper the resource
        let mut tampered = pkg.clone();
        if let Some(pos) = tampered.windows(5).position(|w| w == b"value") {
            tampered[pos] ^= 0xFF;
        }
        let result = verify(&tampered, &key.verifying_key());
        assert!(result.is_err());
    }

    #[test]
    fn with_resources_roundtrip() {
        let key = SigningKey::generate(&mut OsRng);
        let resources = vec![
            ("config/a.toml".to_string(), b"data_a".to_vec()),
            ("config/b.toml".to_string(), b"data_b".to_vec()),
        ];
        let pkg = make_package(&key, b"wasm", resources);
        let result = verify(&pkg, &key.verifying_key()).unwrap();
        assert_eq!(result.manifest.resources.len(), 2);
    }

    fn make_package_with_protos(
        signing_key: &SigningKey,
        binary: &[u8],
        protos: Vec<(String, Vec<u8>)>,
    ) -> Vec<u8> {
        use crate::manifest::ProtoFileEntry;
        let manifest = test_manifest();
        let proto_entries: Vec<ProtoFileEntry> = protos
            .iter()
            .map(|(name, _)| ProtoFileEntry {
                name: name.clone(),
                path: format!("proto/{name}"),
                hash: String::new(),
            })
            .collect();
        let mut m = manifest;
        m.proto_files = proto_entries;
        // PackOptions.proto_files uses (name, data) where name is the raw filename
        // pack() internally creates path as "proto/{name}" and writes to ZIP at that path
        let opts = PackOptions {
            manifest: m,
            binary_bytes: binary.to_vec(),
            resources: vec![],
            proto_files: protos,
            signing_key: signing_key.clone(),
            lock_file: None,
        };
        pack(&opts).unwrap()
    }

    #[test]
    fn with_proto_files_roundtrip() {
        let key = SigningKey::generate(&mut OsRng);
        let protos = vec![
            (
                "echo.proto".to_string(),
                b"syntax = \"proto3\";\nservice Echo {}".to_vec(),
            ),
            (
                "common.proto".to_string(),
                b"syntax = \"proto3\";\nmessage Empty {}".to_vec(),
            ),
        ];
        let pkg = make_package_with_protos(&key, b"wasm", protos);
        let result = verify(&pkg, &key.verifying_key()).unwrap();
        assert_eq!(result.manifest.proto_files.len(), 2);
        assert_eq!(result.manifest.proto_files[0].name, "echo.proto");
        assert_eq!(result.manifest.proto_files[1].name, "common.proto");
    }

    #[test]
    fn tampered_proto_detected() {
        let key = SigningKey::generate(&mut OsRng);
        let protos = vec![(
            "echo.proto".to_string(),
            b"syntax = \"proto3\";\nservice Echo {}".to_vec(),
        )];
        let pkg = make_package_with_protos(&key, b"wasm", protos);
        // Tamper the proto content in the ZIP
        let mut tampered = pkg.clone();
        let needle = b"Echo";
        if let Some(pos) = tampered.windows(needle.len()).position(|w| w == needle) {
            tampered[pos] ^= 0xFF;
        }
        let result = verify(&tampered, &key.verifying_key());
        assert!(result.is_err(), "tampered proto should fail: {:?}", result);
    }
}