greentic_pack/
reader.rs

1use std::collections::{HashMap, HashSet};
2use std::convert::TryInto;
3use std::fs::File;
4use std::io::{Read, Seek};
5use std::path::Path;
6
7use anyhow::{Context, Result, anyhow, bail};
8use base64::Engine;
9use base64::engine::general_purpose::URL_SAFE_NO_PAD;
10use ed25519_dalek::{Signature, Verifier, VerifyingKey};
11use serde::Deserialize;
12use x509_parser::pem::parse_x509_pem;
13use x509_parser::prelude::*;
14use zip::ZipArchive;
15
16use crate::builder::{
17    PackManifest, SBOM_FORMAT, SIGNATURE_CHAIN_PATH, SIGNATURE_PATH, SbomEntry, SignatureEnvelope,
18    hex_hash, signature_digest_from_entries,
19};
20
21#[cfg(test)]
22const MAX_ARCHIVE_BYTES: u64 = 256 * 1024;
23#[cfg(not(test))]
24const MAX_ARCHIVE_BYTES: u64 = 64 * 1024 * 1024;
25
26#[cfg(test)]
27const MAX_FILE_BYTES: u64 = 64 * 1024;
28#[cfg(not(test))]
29const MAX_FILE_BYTES: u64 = 16 * 1024 * 1024;
30
31#[derive(Clone, Copy, Debug, PartialEq, Eq)]
32pub enum SigningPolicy {
33    DevOk,
34    Strict,
35}
36
37#[derive(Debug, Clone, Default)]
38pub struct VerifyReport {
39    pub signature_ok: bool,
40    pub sbom_ok: bool,
41    pub warnings: Vec<String>,
42}
43
44#[derive(Debug, Clone)]
45pub struct PackLoad {
46    pub manifest: PackManifest,
47    pub report: VerifyReport,
48    pub sbom: Vec<SbomEntry>,
49}
50
51#[derive(Debug, Clone)]
52pub struct PackVerifyResult {
53    pub message: String,
54}
55
56impl PackVerifyResult {
57    fn from_error(err: anyhow::Error) -> Self {
58        Self {
59            message: err.to_string(),
60        }
61    }
62}
63
64pub fn open_pack(path: &Path, policy: SigningPolicy) -> Result<PackLoad, PackVerifyResult> {
65    match open_pack_inner(path, policy) {
66        Ok(result) => Ok(result),
67        Err(err) => Err(PackVerifyResult::from_error(err)),
68    }
69}
70
71fn open_pack_inner(path: &Path, policy: SigningPolicy) -> Result<PackLoad> {
72    let mut archive = ZipArchive::new(
73        File::open(path).with_context(|| format!("failed to open {}", path.display()))?,
74    )
75    .with_context(|| format!("{} is not a valid gtpack archive", path.display()))?;
76
77    let (files, total) = read_archive_entries(&mut archive)?;
78    if total > MAX_ARCHIVE_BYTES {
79        bail!(
80            "gtpack archive exceeds maximum allowed size ({} bytes)",
81            MAX_ARCHIVE_BYTES
82        );
83    }
84
85    let manifest_bytes = files
86        .get("manifest.cbor")
87        .cloned()
88        .ok_or_else(|| anyhow!("manifest.cbor missing from archive"))?;
89    let manifest: PackManifest =
90        serde_cbor::from_slice(&manifest_bytes).context("manifest.cbor is invalid")?;
91
92    let sbom_bytes = files
93        .get("sbom.json")
94        .cloned()
95        .ok_or_else(|| anyhow!("sbom.json missing from archive"))?;
96    let sbom_doc: SbomDocument =
97        serde_json::from_slice(&sbom_bytes).context("sbom.json is not valid JSON")?;
98    if sbom_doc.format != SBOM_FORMAT {
99        bail!("unexpected SBOM format: {}", sbom_doc.format);
100    }
101
102    let mut warnings = Vec::new();
103    verify_sbom(&files, &sbom_doc.files)?;
104    verify_signature(
105        &files,
106        &manifest_bytes,
107        &sbom_bytes,
108        &sbom_doc.files,
109        policy,
110        &mut warnings,
111    )?;
112
113    Ok(PackLoad {
114        manifest,
115        report: VerifyReport {
116            signature_ok: true,
117            sbom_ok: true,
118            warnings,
119        },
120        sbom: sbom_doc.files,
121    })
122}
123
124#[derive(Deserialize)]
125struct SbomDocument {
126    format: String,
127    files: Vec<SbomEntry>,
128}
129
130fn verify_sbom(files: &HashMap<String, Vec<u8>>, entries: &[SbomEntry]) -> Result<()> {
131    let mut listed = HashSet::new();
132    for entry in entries {
133        let data = files
134            .get(&entry.path)
135            .ok_or_else(|| anyhow!("sbom references missing file `{}`", entry.path))?;
136        let actual = hex_hash(data);
137        if !actual.eq_ignore_ascii_case(&entry.hash_blake3) {
138            bail!(
139                "hash mismatch for {}: expected {}, found {}",
140                entry.path,
141                entry.hash_blake3,
142                actual
143            );
144        }
145        listed.insert(entry.path.clone());
146    }
147
148    for path in files.keys() {
149        if path == SIGNATURE_PATH || path == SIGNATURE_CHAIN_PATH || path == "sbom.json" {
150            continue;
151        }
152        if !listed.contains(path) {
153            bail!("file `{}` missing from sbom.json", path);
154        }
155    }
156
157    Ok(())
158}
159
160fn verify_signature(
161    files: &HashMap<String, Vec<u8>>,
162    manifest_bytes: &[u8],
163    sbom_bytes: &[u8],
164    entries: &[SbomEntry],
165    policy: SigningPolicy,
166    warnings: &mut Vec<String>,
167) -> Result<()> {
168    let signature_bytes = files
169        .get(SIGNATURE_PATH)
170        .ok_or_else(|| anyhow!("signature file `{}` missing", SIGNATURE_PATH))?;
171    let chain_bytes = files
172        .get(SIGNATURE_CHAIN_PATH)
173        .ok_or_else(|| anyhow!("certificate chain `{}` missing", SIGNATURE_CHAIN_PATH))?;
174
175    let envelope: SignatureEnvelope =
176        serde_json::from_slice(signature_bytes).context("signatures/pack.sig is not valid JSON")?;
177    let digest = signature_digest_from_entries(entries, manifest_bytes, sbom_bytes);
178    let digest_hex = digest.to_hex().to_string();
179    if !digest_hex.eq_ignore_ascii_case(&envelope.digest) {
180        bail!("signature digest mismatch");
181    }
182
183    match envelope.alg.to_ascii_lowercase().as_str() {
184        "ed25519" => verify_ed25519_signature(&envelope, digest, chain_bytes, policy, warnings)?,
185        other => bail!("unsupported signature algorithm: {}", other),
186    }
187
188    Ok(())
189}
190
191fn verify_ed25519_signature(
192    envelope: &SignatureEnvelope,
193    digest: blake3::Hash,
194    chain_bytes: &[u8],
195    policy: SigningPolicy,
196    warnings: &mut Vec<String>,
197) -> Result<()> {
198    let sig_raw = URL_SAFE_NO_PAD
199        .decode(envelope.sig.as_bytes())
200        .map_err(|err| anyhow!("invalid signature encoding: {err}"))?;
201    let sig_array: [u8; 64] = sig_raw
202        .as_slice()
203        .try_into()
204        .map_err(|_| anyhow!("signature must be 64 bytes"))?;
205    let signature = Signature::from_bytes(&sig_array);
206
207    let cert_der = parse_certificate_chain(chain_bytes)?;
208    enforce_policy(&cert_der, policy, warnings)?;
209    let first_cert = parse_certificate(&cert_der[0])?;
210    let verifying_key = extract_ed25519_key(&first_cert)?;
211    verifying_key
212        .verify(digest.as_bytes(), &signature)
213        .map_err(|err| anyhow!("signature verification failed: {err}"))?;
214    Ok(())
215}
216
217fn extract_ed25519_key(cert: &X509Certificate<'_>) -> Result<VerifyingKey> {
218    let spki = cert.public_key();
219    let key_bytes = spki.subject_public_key.data.as_ref();
220    if key_bytes.len() != 32 {
221        bail!(
222            "expected 32-byte Ed25519 public key, found {} bytes",
223            key_bytes.len()
224        );
225    }
226    let mut raw = [0u8; 32];
227    raw.copy_from_slice(key_bytes);
228    VerifyingKey::from_bytes(&raw).map_err(|err| anyhow!("invalid ed25519 key: {err}"))
229}
230
231fn parse_certificate(bytes: &[u8]) -> Result<X509Certificate<'_>> {
232    let (_, cert) =
233        X509Certificate::from_der(bytes).map_err(|err| anyhow!("invalid certificate: {err}"))?;
234    Ok(cert)
235}
236
237fn parse_certificate_chain(mut data: &[u8]) -> Result<Vec<Vec<u8>>> {
238    let mut certs = Vec::new();
239    loop {
240        data = trim_leading(data);
241        if data.is_empty() {
242            break;
243        }
244        let (rest, pem) = parse_x509_pem(data).map_err(|err| anyhow!("invalid PEM: {err}"))?;
245        if pem.label != "CERTIFICATE" {
246            bail!("unexpected PEM label {}; expected CERTIFICATE", pem.label);
247        }
248        certs.push(pem.contents.to_vec());
249        data = rest;
250    }
251
252    if certs.is_empty() {
253        bail!("certificate chain is empty");
254    }
255
256    Ok(certs)
257}
258
259fn enforce_policy(
260    certs: &[Vec<u8>],
261    policy: SigningPolicy,
262    warnings: &mut Vec<String>,
263) -> Result<()> {
264    let first = certs
265        .first()
266        .ok_or_else(|| anyhow!("certificate chain is empty"))?;
267    let first_cert = parse_certificate(first)?;
268    let is_dev = is_dev_certificate(&first_cert);
269
270    match policy {
271        SigningPolicy::DevOk => {
272            if certs.len() != 1 {
273                warnings.push(format!(
274                    "chain contains {} certificates; dev mode expects exactly 1",
275                    certs.len()
276                ));
277            }
278        }
279        SigningPolicy::Strict => {
280            if is_dev {
281                bail!("dev self-signed certificate is not allowed under strict policy");
282            }
283        }
284    }
285
286    Ok(())
287}
288
289fn is_dev_certificate(cert: &X509Certificate<'_>) -> bool {
290    let cn_matches = cert
291        .subject()
292        .iter_common_name()
293        .flat_map(|attr| attr.as_str())
294        .any(|cn| cn == "greentic-dev-local");
295    cn_matches && (cert.subject() == cert.issuer())
296}
297
298fn trim_leading(mut data: &[u8]) -> &[u8] {
299    while let Some((&byte, rest)) = data.split_first() {
300        if byte.is_ascii_whitespace() {
301            data = rest;
302        } else {
303            break;
304        }
305    }
306    data
307}
308
309fn read_archive_entries<R: Read + Seek>(
310    archive: &mut ZipArchive<R>,
311) -> Result<(HashMap<String, Vec<u8>>, u64)> {
312    let mut files = HashMap::new();
313    let mut total = 0u64;
314
315    for idx in 0..archive.len() {
316        let mut entry = archive
317            .by_index(idx)
318            .with_context(|| format!("failed to read entry #{idx}"))?;
319
320        if entry.is_dir() {
321            continue;
322        }
323        if !entry.is_file() {
324            bail!("archive entry {} is not a regular file", entry.name());
325        }
326
327        if let Some(mode) = entry.unix_mode() {
328            let file_type = mode & 0o170000;
329            if file_type != 0o100000 {
330                bail!(
331                    "unsupported file type for entry {}; only regular files are allowed",
332                    entry.name()
333                );
334            }
335        }
336
337        let enclosed_path = entry
338            .enclosed_name()
339            .ok_or_else(|| anyhow!("archive entry contains unsafe path: {}", entry.name()))?
340            .to_path_buf();
341        let logical = normalize_entry_path(&enclosed_path)?;
342        if files.contains_key(&logical) {
343            bail!("duplicate entry detected: {}", logical);
344        }
345
346        let size = entry.size();
347        if size > MAX_FILE_BYTES {
348            bail!(
349                "entry {} exceeds maximum allowed size of {} bytes",
350                logical,
351                MAX_FILE_BYTES
352            );
353        }
354
355        total = total
356            .checked_add(size)
357            .ok_or_else(|| anyhow!("archive size overflow"))?;
358
359        let mut buf = Vec::with_capacity(size as usize);
360        entry
361            .read_to_end(&mut buf)
362            .with_context(|| format!("failed to read {}", logical))?;
363        files.insert(logical, buf);
364    }
365
366    Ok((files, total))
367}
368
369fn normalize_entry_path(path: &Path) -> Result<String> {
370    if path.is_absolute() {
371        bail!("archive entry uses absolute path: {}", path.display());
372    }
373
374    if path.components().any(|comp| {
375        matches!(
376            comp,
377            std::path::Component::ParentDir | std::path::Component::RootDir
378        )
379    }) {
380        bail!(
381            "archive entry contains invalid path segments: {}",
382            path.display()
383        );
384    }
385
386    let mut normalized = Vec::new();
387    for comp in path.components() {
388        match comp {
389            std::path::Component::Normal(seg) => {
390                let segment = seg
391                    .to_str()
392                    .ok_or_else(|| anyhow!("entry contains non-utf8 segment"))?;
393                if segment.is_empty() {
394                    bail!("entry contains empty path segment");
395                }
396                normalized.push(segment.replace('\\', "/"));
397            }
398            std::path::Component::CurDir => continue,
399            _ => bail!(
400                "archive entry contains unsupported segment: {}",
401                path.display()
402            ),
403        }
404    }
405
406    if normalized.is_empty() {
407        bail!("archive entry lacks a valid filename");
408    }
409
410    Ok(normalized.join("/"))
411}
412
413#[cfg(test)]
414mod tests {
415    use super::{MAX_ARCHIVE_BYTES, MAX_FILE_BYTES, SigningPolicy, open_pack};
416    use crate::builder::SIGNATURE_CHAIN_PATH;
417    use crate::builder::{
418        ComponentArtifact, FlowBundle, PackBuilder, PackMeta, Provenance, Signing,
419    };
420    use blake3;
421    use semver::Version;
422    use serde_json::{Map, json};
423    use std::fs::{self, File};
424    use std::io::{Read, Write};
425    use std::path::{Path, PathBuf};
426    use tempfile::{TempDir, tempdir};
427    use zip::write::SimpleFileOptions;
428    use zip::{CompressionMethod, ZipArchive, ZipWriter};
429
430    #[test]
431    fn open_pack_succeeds_for_dev_signature() {
432        let (_dir, path) = build_pack(true);
433        let load = open_pack(&path, SigningPolicy::DevOk).expect("reader validates pack");
434        assert_eq!(load.manifest.meta.pack_id, "ai.greentic.demo.reader");
435        assert!(load.report.warnings.is_empty());
436    }
437
438    #[test]
439    fn open_pack_rejects_missing_signature() {
440        let (_dir, path) = build_pack(false);
441        let err = open_pack(&path, SigningPolicy::DevOk).unwrap_err();
442        assert!(err.message.contains("signature"));
443    }
444
445    #[test]
446    fn strict_policy_rejects_dev_certificate() {
447        let (_dir, path) = build_pack(true);
448        let err = open_pack(&path, SigningPolicy::Strict).unwrap_err();
449        assert!(err.message.contains("strict"));
450    }
451
452    #[test]
453    fn dev_policy_warns_for_multi_certificate_chain() {
454        let (_dir, original) = build_pack(true);
455        let (_tmp, rewritten) = duplicate_chain(&original);
456        let load = open_pack(&rewritten, SigningPolicy::DevOk).expect("dev policy accepts");
457        assert!(load.report.warnings.iter().any(|msg| msg.contains("chain")));
458    }
459
460    #[test]
461    fn path_traversal_entry_is_rejected() {
462        let (_dir, path) = custom_zip(&[zip_entry("../evil", b"oops")]);
463        let err = open_pack(&path, SigningPolicy::DevOk).unwrap_err();
464        assert!(err.message.contains("unsafe path") || err.message.contains("invalid path"));
465    }
466
467    #[test]
468    fn symlink_entry_is_rejected() {
469        let (dir, path) = custom_zip(&[zip_entry("foo", b"bar")]);
470        patch_external_attributes(&path, 0o120777 << 16);
471        let err = open_pack(&path, SigningPolicy::DevOk).unwrap_err();
472        assert!(
473            err.message.contains("unsupported file type")
474                || err.message.contains("not a regular file")
475        );
476        drop(dir);
477    }
478
479    #[test]
480    fn oversized_entry_is_rejected() {
481        let huge = vec![0u8; (MAX_FILE_BYTES + 1) as usize];
482        let (_dir, path) = custom_zip(&[zip_entry("huge.bin", &huge)]);
483        let err = open_pack(&path, SigningPolicy::DevOk).unwrap_err();
484        assert!(err.message.contains("exceeds maximum"));
485    }
486
487    #[test]
488    fn oversized_archive_is_rejected() {
489        let chunk = vec![0u8; (MAX_FILE_BYTES / 2) as usize];
490        let needed = (MAX_ARCHIVE_BYTES / chunk.len() as u64) + 1;
491        let mut entries = Vec::new();
492        for idx in 0..needed {
493            let name = format!("chunk{idx}");
494            entries.push((name, chunk.clone()));
495        }
496        let (_dir, path) = custom_zip(&entries);
497        let err = open_pack(&path, SigningPolicy::DevOk).unwrap_err();
498        assert!(err.message.contains("archive exceeds"));
499    }
500
501    fn temp_wasm(dir: &Path) -> PathBuf {
502        let path = dir.join("component.wasm");
503        std::fs::write(&path, [0x00u8, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00]).unwrap();
504        path
505    }
506
507    fn sample_meta() -> PackMeta {
508        PackMeta {
509            pack_version: crate::builder::PACK_VERSION,
510            pack_id: "ai.greentic.demo.reader".into(),
511            version: Version::parse("0.1.0").unwrap(),
512            name: "Reader Demo".into(),
513            kind: None,
514            description: None,
515            authors: vec!["Greentic".into()],
516            license: None,
517            homepage: None,
518            support: None,
519            vendor: None,
520            imports: vec![],
521            entry_flows: vec!["demo".into()],
522            created_at_utc: "2025-01-01T00:00:00Z".into(),
523            events: None,
524            repo: None,
525            messaging: None,
526            interfaces: Vec::new(),
527            annotations: Map::new(),
528            distribution: None,
529            components: Vec::new(),
530        }
531    }
532
533    fn sample_flow() -> FlowBundle {
534        let json = json!({
535            "id": "demo",
536            "kind": "flow/v1",
537            "entry": "start",
538            "nodes": []
539        });
540        FlowBundle {
541            id: "demo".into(),
542            kind: "flow/v1".into(),
543            entry: "start".into(),
544            yaml: "id: demo\nentry: start\n".into(),
545            json: json.clone(),
546            hash_blake3: blake3::hash(&serde_json::to_vec(&json).unwrap())
547                .to_hex()
548                .to_string(),
549            nodes: Vec::new(),
550        }
551    }
552
553    fn sample_provenance() -> Provenance {
554        Provenance {
555            builder: "greentic-pack@test".into(),
556            git_commit: Some("abc123".into()),
557            git_repo: None,
558            toolchain: None,
559            built_at_utc: "2025-01-01T00:00:00Z".into(),
560            host: None,
561            notes: None,
562        }
563    }
564
565    fn build_pack(include_signature: bool) -> (TempDir, PathBuf) {
566        let dir = tempdir().unwrap();
567        let wasm = temp_wasm(dir.path());
568        let out = dir.path().join("demo.gtpack");
569        let mut builder = PackBuilder::new(sample_meta())
570            .with_flow(sample_flow())
571            .with_component(ComponentArtifact {
572                name: "demo".into(),
573                version: Version::parse("1.0.0").unwrap(),
574                wasm_path: wasm,
575                schema_json: None,
576                manifest_json: None,
577                capabilities: None,
578                world: None,
579                hash_blake3: None,
580            })
581            .with_provenance(sample_provenance());
582        if !include_signature {
583            builder = builder.with_signing(Signing::None);
584        }
585        builder.build(&out).unwrap();
586        (dir, out)
587    }
588
589    fn custom_zip(entries: &[(String, Vec<u8>)]) -> (TempDir, PathBuf) {
590        use zip::DateTime;
591
592        let dir = tempdir().unwrap();
593        let path = dir.path().join("custom.gtpack");
594        let file = File::create(&path).unwrap();
595        let mut writer = ZipWriter::new(file);
596        let timestamp = DateTime::from_date_and_time(1980, 1, 1, 0, 0, 0).unwrap();
597        for (name, data) in entries.iter() {
598            let options = SimpleFileOptions::default()
599                .compression_method(CompressionMethod::Stored)
600                .last_modified_time(timestamp)
601                .unix_permissions(0o644);
602            writer.start_file(name, options).unwrap();
603            writer.write_all(data).unwrap();
604        }
605        writer.finish().unwrap();
606        (dir, path)
607    }
608
609    fn zip_entry(name: &str, data: &[u8]) -> (String, Vec<u8>) {
610        (name.to_string(), data.to_vec())
611    }
612
613    fn patch_external_attributes(path: &Path, attr: u32) {
614        let mut bytes = fs::read(path).unwrap();
615        let signature = [0x50, 0x4b, 0x01, 0x02];
616        let pos = bytes
617            .windows(4)
618            .rposition(|window| window == signature)
619            .expect("central directory missing");
620        let attr_pos = pos + 38;
621        bytes[attr_pos..attr_pos + 4].copy_from_slice(&attr.to_le_bytes());
622        fs::write(path, bytes).unwrap();
623    }
624
625    fn duplicate_chain(original: &Path) -> (TempDir, PathBuf) {
626        use zip::DateTime;
627
628        let mut archive = ZipArchive::new(File::open(original).unwrap()).unwrap();
629        let dir = tempdir().unwrap();
630        let new_path = dir.path().join("rewritten.gtpack");
631        let file = File::create(&new_path).unwrap();
632        let mut writer = ZipWriter::new(file);
633        let timestamp = DateTime::from_date_and_time(1980, 1, 1, 0, 0, 0).unwrap();
634
635        for i in 0..archive.len() {
636            let mut entry = archive.by_index(i).unwrap();
637            let mut data = Vec::new();
638            entry.read_to_end(&mut data).unwrap();
639            if entry.name() == SIGNATURE_CHAIN_PATH {
640                let original = data.clone();
641                data.push(b'\n');
642                data.extend_from_slice(&original);
643            }
644            let options = SimpleFileOptions::default()
645                .compression_method(CompressionMethod::Stored)
646                .last_modified_time(timestamp)
647                .unix_permissions(0o644);
648            writer.start_file(entry.name(), options).unwrap();
649            writer.write_all(&data).unwrap();
650        }
651
652        writer.finish().unwrap();
653        (dir, new_path)
654    }
655}