Skip to main content

zoi_lua/api/
crypto.rs

1//! Cryptographic utilities for the Lua environment.
2//!
3//! This module provides functions to verify file integrity using hashes and
4//! digital signatures, ensuring the security of downloaded assets.
5
6use std::fs;
7use std::path::{Path, PathBuf};
8
9use colored::Colorize;
10use mlua::{self, Lua, Value};
11use sequoia_openpgp::Cert;
12use sequoia_openpgp::parse::Parse;
13use zoi_core::utils;
14/// Exposes cryptographic utilities to the Lua environment.
15///
16/// These functions allow package scripts to perform security validations:
17/// - `verifyHash`: Checks a file's SHA-256 or SHA-512 integrity.
18/// - `verifySignature`: Validates a detached PGP signature using a local or
19///   remote key.
20/// - `addPgpKey`: Dynamically imports a trusted PGP key into the Zoi keyring.
21///
22/// This provides the "Chain of Trust" within the package build process,
23/// ensuring that downloaded assets have not been tampered with.
24///
25/// # Errors
26///
27/// Returns an error if the cryptographic utilities cannot be added to the Lua
28/// environment.
29pub fn add_verify_hash(lua: &Lua, quiet: bool) -> Result<(), mlua::Error> {
30    let verify_hash_fn =
31        lua.create_function(move |lua, args: mlua::MultiValue| {
32            let mut args_iter = args.into_iter();
33            let file_path = match args_iter.next().unwrap_or(Value::Nil) {
34                Value::String(s) => s.to_str()?.to_string(),
35                v => {
36                    return Err(mlua::Error::RuntimeError(format!(
37                        "verifyHash: first argument must be a string, got {}",
38                        v.type_name()
39                    )));
40                }
41            };
42            let hash_str = match args_iter.next().unwrap_or(Value::Nil) {
43                Value::String(s) => s.to_str()?.to_string(),
44                v => {
45                    return Err(mlua::Error::RuntimeError(format!(
46                        "verifyHash: second argument must be a string, got {}",
47                        v.type_name()
48                    )));
49                }
50            };
51
52            let parts: Vec<&str> = hash_str.splitn(2, '-').collect();
53            if parts.len() != 2 {
54                return Err(mlua::Error::RuntimeError(
55                    "Invalid hash format. Expected 'algo-hash'".to_string()
56                ));
57            }
58            let algo = parts.first().copied().unwrap_or_default();
59            let expected_hash = parts.get(1).copied().unwrap_or_default();
60
61            let p = Path::new(&file_path);
62            let actual_path = if p.exists() {
63                p.to_path_buf()
64            } else if let Ok(build_dir) =
65                lua.globals().get::<String>("BUILD_DIR")
66            {
67                Path::new(&build_dir).join(p)
68            } else {
69                p.to_path_buf()
70            };
71
72            let Some(hash_algo) =
73                zoi_core::hash::HashAlgorithm::from_name(algo)
74            else {
75                return Err(mlua::Error::RuntimeError(format!(
76                    "Unsupported hash algorithm: {algo}"
77                )));
78            };
79
80            let actual_hash = match zoi_core::hash::calculate_file_hash(
81                &actual_path,
82                hash_algo
83            ) {
84                Ok(h) => h,
85                Err(e) => {
86                    return Err(mlua::Error::RuntimeError(format!(
87                        "Failed to calculate hash: {e}"
88                    )));
89                }
90            };
91
92            if actual_hash.eq_ignore_ascii_case(expected_hash) {
93                Ok(true)
94            } else {
95                if !quiet {
96                    println!(
97                        "\n{}: Hash mismatch for {}",
98                        "Error".red().bold(),
99                        file_path.cyan()
100                    );
101                    println!("  specified: {algo}-{expected_hash}");
102                    println!("       got:    {algo}-{actual_hash}");
103                }
104                Ok(false)
105            }
106        })?;
107    lua.globals().set("verifyHash", verify_hash_fn)?;
108    Ok(())
109}
110
111/// Adds the `verifySignature` function to the Lua global environment.
112///
113/// This function allows package scripts to validate detached PGP signatures.
114///
115/// # Errors
116///
117/// Returns an error if the `verifySignature` function cannot be added to the
118/// Lua environment.
119pub fn add_verify_signature(lua: &Lua, quiet: bool) -> Result<(), mlua::Error> {
120    let verify_sig_fn =
121        lua.create_function(move |lua, args: mlua::MultiValue| {
122            let mut args_iter = args.into_iter();
123            let file_path = match args_iter.next().unwrap_or(Value::Nil) {
124                Value::String(s) => s.to_str()?.to_string(),
125                v => {
126                    return Err(mlua::Error::RuntimeError(format!(
127                        "verifySignature: first argument must be a string, \
128                         got {}",
129                        v.type_name()
130                    )));
131                }
132            };
133            let sig_path = match args_iter.next().unwrap_or(Value::Nil) {
134                Value::String(s) => s.to_str()?.to_string(),
135                v => {
136                    return Err(mlua::Error::RuntimeError(format!(
137                        "verifySignature: second argument must be a string, \
138                         got {}",
139                        v.type_name()
140                    )));
141                }
142            };
143            let key_source = match args_iter.next().unwrap_or(Value::Nil) {
144                Value::String(s) => s.to_str()?.to_string(),
145                v => {
146                    return Err(mlua::Error::RuntimeError(format!(
147                        "verifySignature: third argument must be a string, \
148                         got {}",
149                        v.type_name()
150                    )));
151                }
152            };
153
154            let resolve_path = |p_str: &str| -> PathBuf {
155                let p = Path::new(p_str);
156                if p.exists() {
157                    p.to_path_buf()
158                } else if let Ok(build_dir) =
159                    lua.globals().get::<String>("BUILD_DIR")
160                {
161                    Path::new(&build_dir).join(p)
162                } else {
163                    p.to_path_buf()
164                }
165            };
166
167            let key_bytes: Vec<u8> = if key_source.starts_with("http") {
168                let client = utils::get_http_client()
169                    .map_err(|e| mlua::Error::RuntimeError(e.to_string()))?;
170                #[allow(clippy::redundant_closure_for_method_calls)]
171                match client.get(&key_source).send().and_then(|r| r.bytes()) {
172                    Ok(b) => b.to_vec(),
173                    Err(e) => {
174                        return Err(mlua::Error::RuntimeError(format!(
175                            "Failed to download key: {e}"
176                        )));
177                    }
178                }
179            } else {
180                let resolved_key_path = resolve_path(&key_source);
181                if resolved_key_path.exists() {
182                    match fs::read(&resolved_key_path) {
183                        Ok(b) => b,
184                        Err(e) => {
185                            return Err(mlua::Error::RuntimeError(format!(
186                                "Failed to read key file {}: {e}",
187                                resolved_key_path.display()
188                            )));
189                        }
190                    }
191                } else {
192                    let pgp_dir = match zoi_core::pgp::get_pgp_dir() {
193                        Ok(dir) => dir,
194                        Err(e) => {
195                            return Err(mlua::Error::RuntimeError(format!(
196                                "Failed to get PGP dir: {e}"
197                            )));
198                        }
199                    };
200                    let key_path = pgp_dir.join(format!("{key_source}.asc"));
201                    if !key_path.exists() {
202                        return Err(mlua::Error::RuntimeError(format!(
203                            "Key with name '{key_source}' not found (checked \
204                             locally and at {}).",
205                            resolved_key_path.display()
206                        )));
207                    }
208                    match fs::read(&key_path) {
209                        Ok(b) => b,
210                        Err(e) => {
211                            return Err(mlua::Error::RuntimeError(format!(
212                                "Failed to read key file {}: {e}",
213                                key_path.display()
214                            )));
215                        }
216                    }
217                }
218            };
219
220            let cert = match Cert::from_bytes(&key_bytes) {
221                Ok(c) => c,
222                Err(e) => {
223                    return Err(mlua::Error::RuntimeError(format!(
224                        "Invalid PGP key: {e}"
225                    )));
226                }
227            };
228
229            let final_file_path = resolve_path(&file_path);
230            let final_sig_path = resolve_path(&sig_path);
231
232            let result = zoi_core::pgp::verify_detached_signature(
233                &final_file_path,
234                &final_sig_path,
235                &cert
236            );
237
238            match result {
239                Ok(()) => Ok(true),
240                Err(e) => {
241                    if !quiet {
242                        eprintln!("Signature verification failed: {e}");
243                    }
244                    Ok(false)
245                }
246            }
247        })?;
248    lua.globals().set("verifySignature", verify_sig_fn)?;
249    Ok(())
250}
251
252/// Reports whether `source` is a bare `OpenPGP` key fingerprint rather than a
253/// path or a URL.
254///
255/// A v4 fingerprint is 40 hex characters and a v5 fingerprint is 64, and both
256/// are frequently written in space-separated groups. The check is deliberately
257/// strict: it only matches those two exact shapes, so an unusual relative path
258/// is still treated as a path and produces the ordinary "file not found"
259/// error rather than being sent to a keyserver.
260fn is_key_fingerprint(source: &str) -> bool {
261    let compact: String =
262        source.chars().filter(|c| !c.is_whitespace()).collect();
263
264    if !compact.chars().all(|c| c.is_ascii_hexdigit()) {
265        return false;
266    }
267
268    matches!(compact.len(), 40 | 64)
269}
270
271/// Adds the `addPgpKey` function to the Lua global environment.
272///
273/// This function allows package scripts to import trusted PGP keys.
274///
275/// The first argument may be a URL, a path to a key file, or a bare key
276/// fingerprint. A fingerprint is fetched from `keys.openpgp.org`, which is what
277/// a package definition wants when it pins a specific maintainer key rather
278/// than trusting whatever a name happens to resolve to.
279///
280/// # Errors
281///
282/// Returns an error if the `addPgpKey` function cannot be added to the Lua
283/// environment.
284pub fn add_add_pgp_key(lua: &Lua, quiet: bool) -> Result<(), mlua::Error> {
285    let add_pgp_key_fn =
286        lua.create_function(move |lua, args: mlua::MultiValue| {
287            let mut args_iter = args.into_iter();
288            let source = match args_iter.next().unwrap_or(Value::Nil) {
289                Value::String(s) => s.to_str()?.to_string(),
290                v => {
291                    return Err(mlua::Error::RuntimeError(format!(
292                        "addPgpKey: first argument must be a string, got {}",
293                        v.type_name()
294                    )));
295                }
296            };
297            let name = match args_iter.next().unwrap_or(Value::Nil) {
298                Value::String(s) => s.to_str()?.to_string(),
299                v => {
300                    return Err(mlua::Error::RuntimeError(format!(
301                        "addPgpKey: second argument must be a string, got {}",
302                        v.type_name()
303                    )));
304                }
305            };
306
307            let result = if source.starts_with("http") {
308                zoi_core::pgp::add_key_from_url(&source, &name, quiet)
309            } else if is_key_fingerprint(&source) {
310                // A bare fingerprint is fetched from the keyserver rather than
311                // looked up as a path. Without this branch a fingerprint falls
312                // through to `add_key_from_path` and fails with "Key file not
313                // found at: <fingerprint>", which is a confusing way to learn
314                // that the capability exists one layer down in `zoi_core`.
315                zoi_core::pgp::add_key_from_fingerprint(&source, &name, quiet)
316            } else {
317                let p = Path::new(&source);
318                let actual_path = if p.exists() {
319                    p.to_path_buf()
320                } else if let Ok(build_dir) =
321                    lua.globals().get::<String>("BUILD_DIR")
322                {
323                    Path::new(&build_dir).join(p)
324                } else {
325                    p.to_path_buf()
326                };
327                zoi_core::pgp::add_key_from_path(
328                    actual_path.to_str().unwrap_or(&source),
329                    Some(&name),
330                    quiet
331                )
332            };
333
334            if let Err(e) = result {
335                if !quiet {
336                    eprintln!("Failed to add PGP key '{name}': {e}");
337                }
338                return Ok(false);
339            }
340            Ok(true)
341        })?;
342    lua.globals().set("addPgpKey", add_pgp_key_fn)?;
343    Ok(())
344}
345
346#[cfg(test)]
347mod tests {
348    use super::is_key_fingerprint;
349
350    #[test]
351    fn accepts_a_v4_fingerprint() {
352        // The format Arch's shadow PKGBUILD records in `validpgpkeys`.
353        assert!(is_key_fingerprint(
354            "66D0387DB85D320F8408166DB175CFA98F192AF2"
355        ));
356    }
357
358    #[test]
359    fn accepts_a_v5_fingerprint() {
360        assert!(is_key_fingerprint(
361            "0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF"
362        ));
363    }
364
365    #[test]
366    fn accepts_a_spaced_fingerprint() {
367        // GnuPG displays fingerprints in space-separated groups, and a
368        // maintainer pasting one out of `gpg --fingerprint` should not have to
369        // strip the spaces first.
370        assert!(is_key_fingerprint(
371            "66D0 387D B85D 320F 8408 166D B175 CFA9 8F19 2AF2"
372        ));
373    }
374
375    #[test]
376    fn is_case_insensitive() {
377        assert!(is_key_fingerprint(
378            "66d0387db85d320f8408166db175cfa98f192af2"
379        ));
380    }
381
382    #[test]
383    fn rejects_paths_and_urls() {
384        // These are the two forms that must keep routing to the existing
385        // branches rather than being sent to a keyserver.
386        assert!(!is_key_fingerprint("keys/some-key.asc"));
387        assert!(!is_key_fingerprint("https://example.com/keys/some-key.asc"));
388        assert!(!is_key_fingerprint("./key.asc"));
389    }
390
391    #[test]
392    fn rejects_wrong_lengths_and_non_hex() {
393        // Too short, too long, and hex-looking but containing a non-hex
394        // character all have to fall through to the path branch, because a
395        // malformed fingerprint should surface as "file not found" rather
396        // than as a keyserver request for nonsense.
397        assert!(!is_key_fingerprint(
398            "66D0387DB85D320F8408166DB175CFA98F192A"
399        ));
400        assert!(!is_key_fingerprint(
401            "66D0387DB85D320F8408166DB175CFA98F192AF266D0387DB85D320F8408166DB175"
402        ));
403        assert!(!is_key_fingerprint(
404            "66D0387DB85D320F8408166DB175CFA98F192AZZ"
405        ));
406        assert!(!is_key_fingerprint(""));
407    }
408}