Skip to main content

confium_registry/
verify.rs

1//! Signature verification.
2//!
3//! Two layers are kept deliberately separate:
4//!
5//! - **Cryptographic layer** — [`verify_signature`] checks a single
6//!   detached PGP signature against the artifact bytes using the
7//!   publisher's public key. It is a pure "does this signature hold?"
8//!   answer with no notion of trust.
9//! - **Policy layer** — [`check`] decides whether the set of signers
10//!   that produced valid signatures intersects with the user's
11//!   [`TrustStore`]. Only the policy layer can produce
12//!   [`Error::UntrustedPlugin`].
13//!
14//! # Backend
15//!
16//! The cryptographic layer prefers the in-process RNP library (Ribose's
17//! OpenPGP implementation, loaded via `libloading`). When `librnp` is
18//! not loadable — e.g. it isn't installed on the host yet — the
19//! verifier falls back to shelling out to `gpg --verify`. The fallback
20//! exists so the trust model is enforceable in environments without a
21//! pre-built `librnp`; once `rnp-rs` (see `TODO.roadmap/13-rnp-rust-binding.md`)
22//! ships, the fallback will be removed and RNP becomes the sole backend.
23//!
24//! See `TODO.roadmap/06-module-registry.md` for the trust model
25//! (publisher identity = PGP key registered in `publishers/`, artifact
26//! signature = detached PGP in `sigs/`).
27
28use crate::error::{Error, Result};
29use crate::trust::TrustStore;
30
31/// The outcome of a signature check.
32#[derive(Debug, Clone, PartialEq, Eq)]
33pub enum Verification {
34    /// At least one `signer` matched a trusted publisher.
35    Verified { signers: Vec<String> },
36    /// No trusted publisher signed the artifact. The caller may still
37    /// proceed if `allow_untrusted` is set (development escape hatch).
38    Unverified { signers: Vec<String> },
39}
40
41impl Verification {
42    /// True if the artifact passed the trust policy.
43    pub fn is_verified(&self) -> bool {
44        matches!(self, Verification::Verified { .. })
45    }
46
47    /// The publisher names that signed the artifact (regardless of
48    /// whether any were trusted).
49    pub fn signers(&self) -> &[String] {
50        match self {
51            Verification::Verified { signers } | Verification::Unverified { signers } => signers,
52        }
53    }
54}
55
56/// Apply the trust policy: the artifact is trusted iff at least one of
57/// `signers` is present in `trust`. Returns
58/// [`Error::UntrustedPlugin`] when unverified and `allow_untrusted` is
59/// false.
60pub fn check(
61    plugin_name: &str,
62    signers: &[String],
63    trust: &TrustStore,
64    allow_untrusted: bool,
65) -> Result<Verification> {
66    let any_trusted = signers.iter().any(|s| trust.contains(s).unwrap_or(false));
67    if any_trusted {
68        Ok(Verification::Verified {
69            signers: signers.to_vec(),
70        })
71    } else if allow_untrusted {
72        Ok(Verification::Unverified {
73            signers: signers.to_vec(),
74        })
75    } else {
76        Err(Error::UntrustedPlugin {
77            name: plugin_name.to_string(),
78        })
79    }
80}
81
82// ---------------------------------------------------------------------------
83// Cryptographic layer
84// ---------------------------------------------------------------------------
85
86/// Verify a single detached PGP signature.
87///
88/// `artifact` is the raw bytes of the thing that was signed. `signature`
89/// is the detached signature (ASCII-armored or binary). `pubkey` is the
90/// publisher's public key in OpenPGP form (also ASCII-armored or
91/// binary).
92///
93/// Returns `Ok(())` when the signature is valid, `Err(...)` otherwise.
94/// The error distinguishes between:
95/// - signature-format problems ([`Error::SignatureFormat`],
96///   [`Error::PublicKeyFormat`]),
97/// - the RNP library not being loadable ([`Error::RnpLoad`]),
98/// - RNP itself rejecting the operation ([`Error::RnpVerify`]),
99/// - and a syntactically valid but cryptographically bad signature
100///   ([`Error::SignatureInvalid`]).
101///
102/// # Backend selection
103///
104/// Prefers in-process RNP via `libloading`. If `librnp` cannot be
105/// loaded, falls back to `gpg --verify` (transitional). The fallback is
106/// gated behind the [`verify_via_gpg`] helper so it can be removed
107/// cleanly once `rnp-rs` lands.
108pub fn verify_signature(artifact: &[u8], signature: &[u8], pubkey: &[u8]) -> Result<()> {
109    match load_librnp() {
110        Ok(lib) => verify_via_rnp(&lib, artifact, signature, pubkey),
111        // RNP not loadable — fall back to `gpg --verify`. We deliberately
112        // do NOT wrap the gpg result: the gpg path returns its own typed
113        // errors (e.g. [`Error::SignatureInvalid`]) so callers can match
114        // on them regardless of which backend ran. The RNP load failure
115        // is logged via [`tracing`] when a logger is configured; the
116        // caller sees the semantically meaningful gpg error.
117        Err(_load_err) => verify_via_gpg(artifact, signature, pubkey),
118    }
119}
120
121/// Candidate library filenames tried, in order, when locating `librnp`.
122///
123/// `libloading::Library::new` already searches the platform's standard
124/// library paths; the explicit names here let us cover the
125/// platform-specific SONAMEs (`librnp.dylib` on macOS, `librnp.so` on
126/// Linux, `rnp.dll` on Windows) without requiring the caller to know
127/// the host OS.
128const LIBRNP_CANDIDATES: &[&str] = &["librnp.dylib", "librnp.so", "rnp.dll", "librnp"];
129
130fn load_librnp() -> std::result::Result<libloading::Library, String> {
131    let mut last: Option<String> = None;
132    for name in LIBRNP_CANDIDATES {
133        match unsafe { libloading::Library::new(*name) } {
134            Ok(lib) => return Ok(lib),
135            Err(e) => last = Some(format!("{name}: {e}")),
136        }
137    }
138    Err(last.unwrap_or_else(|| "no candidate names".to_string()))
139}
140
141/// Verify via the RNP C FFI.
142///
143/// Mirrors the sequence documented in
144/// `~/src/rnp/rnp/include/rnp/rnp.h`:
145///
146/// 1. `rnp_ffi_create("GPG", "GPG")` — top-level handle. Both rings are
147///    GPG-format because that's what an `.asc` file is.
148/// 2. `rnp_load_keys` with the publisher's public key.
149/// 3. `rnp_op_verify_detached_create` over the artifact + signature.
150/// 4. `rnp_op_verify_execute` — runs the verification.
151/// 5. Inspect each signature via
152///    `rnp_op_verify_get_signature_at` +
153///    `rnp_op_verify_signature_get_status`. A signature is valid iff
154///    its status is `RNP_SUCCESS`.
155fn verify_via_rnp(
156    lib: &libloading::Library,
157    artifact: &[u8],
158    signature: &[u8],
159    pubkey: &[u8],
160) -> Result<()> {
161    // ---- function pointer typedefs matching rnp.h ----
162    type RnpFfiCreateFn = unsafe extern "C" fn(
163        *mut ffi::RnpFfi,
164        *const std::os::raw::c_char,
165        *const std::os::raw::c_char,
166    ) -> ffi::RnpResult;
167    type RnpFfiDestroyFn = unsafe extern "C" fn(ffi::RnpFfi) -> ffi::RnpResult;
168    type RnpInputFromMemoryFn =
169        unsafe extern "C" fn(*mut ffi::RnpInput, *const u8, usize, ffi::RnpBool) -> ffi::RnpResult;
170    type RnpInputDestroyFn = unsafe extern "C" fn(ffi::RnpInput) -> ffi::RnpResult;
171    type RnpLoadKeysFn = unsafe extern "C" fn(
172        ffi::RnpFfi,
173        *const std::os::raw::c_char,
174        ffi::RnpInput,
175        u32,
176    ) -> ffi::RnpResult;
177    type RnpOpVerifyDetachedCreateFn = unsafe extern "C" fn(
178        *mut ffi::RnpOpVerify,
179        ffi::RnpFfi,
180        ffi::RnpInput,
181        ffi::RnpInput,
182    ) -> ffi::RnpResult;
183    type RnpOpVerifyExecuteFn = unsafe extern "C" fn(ffi::RnpOpVerify) -> ffi::RnpResult;
184    type RnpOpVerifyDestroyFn = unsafe extern "C" fn(ffi::RnpOpVerify) -> ffi::RnpResult;
185    type RnpOpVerifyGetSignatureCountFn =
186        unsafe extern "C" fn(ffi::RnpOpVerify, *mut usize) -> ffi::RnpResult;
187    type RnpOpVerifyGetSignatureAtFn =
188        unsafe extern "C" fn(ffi::RnpOpVerify, usize, *mut ffi::RnpOpVerifySig) -> ffi::RnpResult;
189    type RnpOpVerifySignatureGetStatusFn =
190        unsafe extern "C" fn(ffi::RnpOpVerifySig) -> ffi::RnpResult;
191
192    // ---- resolve symbols ----
193    macro_rules! sym {
194        ($name:literal, $ty:ty) => {{
195            let name_str = std::str::from_utf8($name).unwrap_or("<non-utf8 symbol>");
196            match unsafe { lib.get::<$ty>($name) } {
197                Ok(f) => *f,
198                Err(e) => {
199                    return Err(Error::RnpLoad {
200                        message: format!("symbol {name_str} not found: {e}"),
201                    });
202                }
203            }
204        }};
205    }
206
207    let ffi_create: RnpFfiCreateFn = sym!(b"rnp_ffi_create\0", RnpFfiCreateFn);
208    let ffi_destroy: RnpFfiDestroyFn = sym!(b"rnp_ffi_destroy\0", RnpFfiDestroyFn);
209    let input_from_memory: RnpInputFromMemoryFn =
210        sym!(b"rnp_input_from_memory\0", RnpInputFromMemoryFn);
211    let input_destroy: RnpInputDestroyFn = sym!(b"rnp_input_destroy\0", RnpInputDestroyFn);
212    let load_keys: RnpLoadKeysFn = sym!(b"rnp_load_keys\0", RnpLoadKeysFn);
213    let op_verify_detached_create: RnpOpVerifyDetachedCreateFn = sym!(
214        b"rnp_op_verify_detached_create\0",
215        RnpOpVerifyDetachedCreateFn
216    );
217    let op_verify_execute: RnpOpVerifyExecuteFn =
218        sym!(b"rnp_op_verify_execute\0", RnpOpVerifyExecuteFn);
219    let op_verify_destroy: RnpOpVerifyDestroyFn =
220        sym!(b"rnp_op_verify_destroy\0", RnpOpVerifyDestroyFn);
221    let get_sig_count: RnpOpVerifyGetSignatureCountFn = sym!(
222        b"rnp_op_verify_get_signature_count\0",
223        RnpOpVerifyGetSignatureCountFn
224    );
225    let get_sig_at: RnpOpVerifyGetSignatureAtFn = sym!(
226        b"rnp_op_verify_get_signature_at\0",
227        RnpOpVerifyGetSignatureAtFn
228    );
229    let get_sig_status: RnpOpVerifySignatureGetStatusFn = sym!(
230        b"rnp_op_verify_signature_get_status\0",
231        RnpOpVerifySignatureGetStatusFn
232    );
233
234    // ---- run the verification ----
235    // SAFETY: every call below passes either a freshly-created opaque
236    // handle or a borrowed byte slice whose lifetime outlives the call.
237    // The handles are paired with their destroyers in the same scope.
238    unsafe {
239        let mut ffi_handle: ffi::RnpFfi = std::ptr::null_mut();
240        let gpg = b"GPG\0";
241        let rc = (ffi_create)(
242            &mut ffi_handle as *mut ffi::RnpFfi,
243            gpg.as_ptr() as *const std::os::raw::c_char,
244            gpg.as_ptr() as *const std::os::raw::c_char,
245        );
246        if rc != ffi::RNP_SUCCESS || ffi_handle.is_null() {
247            return Err(Error::RnpVerify {
248                message: format!("rnp_ffi_create failed (rc={rc:#x})"),
249            });
250        }
251        // RAII guard so the FFI handle is always destroyed.
252        struct FfiGuard {
253            handle: ffi::RnpFfi,
254            destroy: RnpFfiDestroyFn,
255        }
256        impl Drop for FfiGuard {
257            fn drop(&mut self) {
258                if !self.handle.is_null() {
259                    unsafe { (self.destroy)(self.handle) };
260                }
261            }
262        }
263        let ffi_guard = FfiGuard {
264            handle: ffi_handle,
265            destroy: ffi_destroy,
266        };
267
268        // Load the publisher's public key.
269        let mut key_input: ffi::RnpInput = std::ptr::null_mut();
270        let rc = (input_from_memory)(
271            &mut key_input as *mut ffi::RnpInput,
272            pubkey.as_ptr(),
273            pubkey.len(),
274            ffi::RNP_TRUE,
275        );
276        if rc != ffi::RNP_SUCCESS {
277            return Err(Error::PublicKeyFormat {
278                path: "<bytes>".to_string(),
279            });
280        }
281        let key_guard = InputGuard {
282            handle: key_input,
283            destroy: input_destroy,
284        };
285        let rc = (load_keys)(
286            ffi_guard.handle,
287            gpg.as_ptr() as *const std::os::raw::c_char,
288            key_input,
289            ffi::RNP_LOAD_SAVE_PUBLIC_KEYS,
290        );
291        drop(key_guard);
292        if rc != ffi::RNP_SUCCESS {
293            return Err(Error::PublicKeyFormat {
294                path: "<bytes>".to_string(),
295            });
296        }
297
298        // Build the artifact + signature inputs.
299        let mut data_input: ffi::RnpInput = std::ptr::null_mut();
300        let rc = (input_from_memory)(
301            &mut data_input as *mut ffi::RnpInput,
302            artifact.as_ptr(),
303            artifact.len(),
304            ffi::RNP_TRUE,
305        );
306        if rc != ffi::RNP_SUCCESS {
307            return Err(Error::RnpVerify {
308                message: format!("rnp_input_from_memory (artifact) failed (rc={rc:#x})"),
309            });
310        }
311        let data_guard = InputGuard {
312            handle: data_input,
313            destroy: input_destroy,
314        };
315
316        let mut sig_input: ffi::RnpInput = std::ptr::null_mut();
317        let rc = (input_from_memory)(
318            &mut sig_input as *mut ffi::RnpInput,
319            signature.as_ptr(),
320            signature.len(),
321            ffi::RNP_TRUE,
322        );
323        if rc != ffi::RNP_SUCCESS {
324            return Err(Error::SignatureFormat {
325                path: "<bytes>".to_string(),
326            });
327        }
328        let sig_guard = InputGuard {
329            handle: sig_input,
330            destroy: input_destroy,
331        };
332
333        let mut op: ffi::RnpOpVerify = std::ptr::null_mut();
334        let rc = (op_verify_detached_create)(
335            &mut op as *mut ffi::RnpOpVerify,
336            ffi_guard.handle,
337            data_input,
338            sig_input,
339        );
340        if rc != ffi::RNP_SUCCESS || op.is_null() {
341            return Err(Error::RnpVerify {
342                message: format!("rnp_op_verify_detached_create failed (rc={rc:#x})"),
343            });
344        }
345        let op_guard = OpVerifyGuard {
346            handle: op,
347            destroy: op_verify_destroy,
348        };
349
350        // Execute. By default RNP returns success when at least one
351        // signature is valid; we explicitly check each signature
352        // afterward so we can report the exact status.
353        let rc = (op_verify_execute)(op);
354        if rc != ffi::RNP_SUCCESS {
355            // Execute failed outright — likely a malformed signature or
356            // unreadable data. Surface as invalid rather than RNP-internal
357            // so callers can distinguish "couldn't run" from "ran, bad".
358            return Err(Error::SignatureInvalid {
359                message: format!("rnp_op_verify_execute failed (rc={rc:#x})"),
360            });
361        }
362
363        // Walk signatures. We need at least one RNP_SUCCESS status.
364        let mut count: usize = 0;
365        let rc = (get_sig_count)(op, &mut count as *mut usize);
366        if rc != ffi::RNP_SUCCESS {
367            return Err(Error::RnpVerify {
368                message: format!("rnp_op_verify_get_signature_count failed (rc={rc:#x})"),
369            });
370        }
371        if count == 0 {
372            return Err(Error::SignatureInvalid {
373                message: "no signatures present".to_string(),
374            });
375        }
376
377        let mut last_status: u32 = 0;
378        let mut saw_valid = false;
379        for idx in 0..count {
380            let mut sig: ffi::RnpOpVerifySig = std::ptr::null_mut();
381            let rc = (get_sig_at)(op, idx, &mut sig as *mut ffi::RnpOpVerifySig);
382            if rc != ffi::RNP_SUCCESS {
383                return Err(Error::RnpVerify {
384                    message: format!("rnp_op_verify_get_signature_at({idx}) failed (rc={rc:#x})"),
385                });
386            }
387            let status = (get_sig_status)(sig);
388            last_status = status;
389            if status == ffi::RNP_SUCCESS {
390                saw_valid = true;
391            }
392        }
393
394        drop(op_guard);
395        drop(sig_guard);
396        drop(data_guard);
397        drop(ffi_guard);
398
399        if saw_valid {
400            Ok(())
401        } else {
402            Err(Error::SignatureInvalid {
403                message: format!("no valid signature (last status={last_status:#x})"),
404            })
405        }
406    }
407}
408
409struct InputGuard {
410    handle: ffi::RnpInput,
411    destroy: unsafe extern "C" fn(ffi::RnpInput) -> ffi::RnpResult,
412}
413impl Drop for InputGuard {
414    fn drop(&mut self) {
415        if !self.handle.is_null() {
416            unsafe { (self.destroy)(self.handle) };
417        }
418    }
419}
420
421struct OpVerifyGuard {
422    handle: ffi::RnpOpVerify,
423    destroy: unsafe extern "C" fn(ffi::RnpOpVerify) -> ffi::RnpResult,
424}
425impl Drop for OpVerifyGuard {
426    fn drop(&mut self) {
427        if !self.handle.is_null() {
428            unsafe { (self.destroy)(self.handle) };
429        }
430    }
431}
432
433/// Transitional verifier: shell out to `gpg --verify`.
434///
435/// Writes the three byte slices into a scratch tempdir, then invokes
436/// `gpg --verify <sig> <data>` with a keyring pointed at the publisher's
437/// pubkey. The exit code distinguishes valid (0) from invalid (1).
438///
439/// This is intentionally simple: it's the path that runs when `librnp`
440/// isn't available. Once `rnp-rs` is wired in this whole function (and
441/// its caller in [`verify_signature`]) will be removed.
442fn verify_via_gpg(artifact: &[u8], signature: &[u8], pubkey: &[u8]) -> Result<()> {
443    use std::io::Write;
444    use std::process::Command;
445
446    // Use a process-unique scratch dir under the system temp rather
447    // than pulling `tempfile` as a runtime dependency. We clean up at
448    // the end; if cleanup fails the OS will reap it on reboot.
449    let scratch = std::env::temp_dir().join(format!(
450        "confium-verify-{}-{}",
451        std::process::id(),
452        scratch_counter()
453    ));
454    std::fs::create_dir_all(&scratch).map_err(|e| Error::VerificationSubprocess {
455        message: format!("failed to create tempdir {}: {e}", scratch.display()),
456    })?;
457
458    let key_path = scratch.join("pubkey.asc");
459    let data_path = scratch.join("artifact.bin");
460    let sig_path = scratch.join("sig.asc");
461    let gpg_home = scratch.join("gpghome");
462
463    let write_file = |path: &std::path::Path, body: &[u8], what: &str| -> Result<()> {
464        let mut f = std::fs::File::create(path).map_err(|e| Error::VerificationSubprocess {
465            message: format!("failed to create {what} {}: {e}", path.display()),
466        })?;
467        f.write_all(body)
468            .map_err(|e| Error::VerificationSubprocess {
469                message: format!("failed to write {what} {}: {e}", path.display()),
470            })?;
471        Ok(())
472    };
473
474    write_file(&key_path, pubkey, "pubkey")?;
475    write_file(&data_path, artifact, "artifact")?;
476    write_file(&sig_path, signature, "signature")?;
477
478    // gpg insists on 0700 perms on its home dir.
479    std::fs::create_dir_all(&gpg_home).map_err(|e| Error::VerificationSubprocess {
480        message: format!("failed to create gpghome: {e}"),
481    })?;
482    #[cfg(unix)]
483    {
484        use std::os::unix::fs::PermissionsExt;
485        std::fs::set_permissions(&gpg_home, std::fs::Permissions::from_mode(0o700)).map_err(
486            |e| Error::VerificationSubprocess {
487                message: format!("failed to chmod gpghome: {e}"),
488            },
489        )?;
490    }
491
492    let gnupg_arg = format!("{}", gpg_home.display());
493
494    let import = Command::new("gpg")
495        .args(["--homedir", &gnupg_arg, "--import"])
496        .arg(&key_path)
497        .output()
498        .map_err(|e| Error::VerificationSubprocess {
499            message: format!("failed to invoke gpg --import: {e}"),
500        })?;
501    if !import.status.success() {
502        let _ = std::fs::remove_dir_all(&scratch);
503        return Err(Error::VerificationSubprocess {
504            message: format!(
505                "gpg --import failed: {}",
506                String::from_utf8_lossy(&import.stderr).trim()
507            ),
508        });
509    }
510
511    let verify = Command::new("gpg")
512        .args(["--homedir", &gnupg_arg, "--verify"])
513        .arg(&sig_path)
514        .arg(&data_path)
515        .output()
516        .map_err(|e| Error::VerificationSubprocess {
517            message: format!("failed to invoke gpg --verify: {e}"),
518        })?;
519
520    // Best-effort cleanup. We don't care if it fails.
521    let _ = std::fs::remove_dir_all(&scratch);
522
523    if verify.status.success() {
524        Ok(())
525    } else {
526        Err(Error::SignatureInvalid {
527            message: format!(
528                "gpg --verify rejected signature: {}",
529                String::from_utf8_lossy(&verify.stderr).trim()
530            ),
531        })
532    }
533}
534
535/// Monotonic counter to ensure each `verify_via_gpg` invocation gets a
536/// unique scratch directory even when called concurrently from the same
537/// process. Uses `AtomicU64` so concurrent calls don't collide.
538fn scratch_counter() -> u64 {
539    use std::sync::atomic::{AtomicU64, Ordering};
540    static COUNTER: AtomicU64 = AtomicU64::new(0);
541    COUNTER.fetch_add(1, Ordering::Relaxed)
542}
543
544/// Minimal FFI type aliases for RNP. Kept private; once `rnp-rs` ships
545/// these move into the binding crate.
546mod ffi {
547    pub type RnpResult = u32;
548    pub type RnpBool = bool;
549
550    pub type RnpFfi = *mut std::os::raw::c_void;
551    pub type RnpInput = *mut std::os::raw::c_void;
552    pub type RnpOpVerify = *mut std::os::raw::c_void;
553    pub type RnpOpVerifySig = *mut std::os::raw::c_void;
554
555    /// RNP_SUCCESS — see `rnp_err.h` in the RNP source tree.
556    pub const RNP_SUCCESS: RnpResult = 0;
557    pub const RNP_TRUE: RnpBool = true;
558
559    /// `RNP_LOAD_SAVE_PUBLIC_KEYS` from `rnp.h`.
560    pub const RNP_LOAD_SAVE_PUBLIC_KEYS: u32 = 1 << 0;
561}
562
563#[cfg(test)]
564mod tests {
565    use super::*;
566    use crate::manifest::TrustRoot;
567    use crate::trust::TrustStore;
568    use std::path::PathBuf;
569    use tempfile::tempdir;
570
571    fn root(name: &str) -> TrustRoot {
572        TrustRoot {
573            name: name.to_string(),
574            key_id: "0x1".to_string(),
575            fingerprint: "AAAA".to_string(),
576            key_url: format!("/publishers/{}.asc", name),
577        }
578    }
579
580    fn store_at(dir: &tempfile::TempDir) -> TrustStore {
581        TrustStore::for_home(PathBuf::from(dir.path()))
582    }
583
584    #[test]
585    fn verifies_when_trusted_signer_present() {
586        let dir = tempdir().unwrap();
587        let store = store_at(&dir);
588        store.add(root("ribose")).unwrap();
589        let v = check("botan", &["ribose".to_string()], &store, false).unwrap();
590        assert!(v.is_verified());
591    }
592
593    #[test]
594    fn refuses_untrusted_without_override() {
595        let dir = tempdir().unwrap();
596        let store = store_at(&dir);
597        let err = check("botan", &["stranger".to_string()], &store, false).unwrap_err();
598        assert!(matches!(err, Error::UntrustedPlugin { .. }));
599    }
600
601    #[test]
602    fn allows_untrusted_with_override() {
603        let dir = tempdir().unwrap();
604        let store = store_at(&dir);
605        let v = check("botan", &["stranger".to_string()], &store, true).unwrap();
606        assert!(!v.is_verified());
607        assert_eq!(v.signers(), &["stranger"]);
608    }
609}
610
611#[cfg(test)]
612mod pgp_tests {
613    use super::*;
614    use std::fs;
615    use std::path::PathBuf;
616    use std::process::Command;
617    use tempfile::TempDir;
618
619    /// Skip the whole module if `gpg` isn't on PATH. The CI images used
620    /// for this workspace ship gpg, but a developer running `cargo test`
621    /// locally shouldn't see spurious failures just because they lack
622    /// it.
623    fn gpg_path() -> Option<PathBuf> {
624        super::which_shim::which("gpg").or_else(|| {
625            let out = Command::new("sh")
626                .args(["-c", "command -v gpg"])
627                .output()
628                .ok()?;
629            if !out.status.success() {
630                return None;
631            }
632            let s = String::from_utf8_lossy(&out.stdout).trim().to_string();
633            if s.is_empty() {
634                None
635            } else {
636                Some(PathBuf::from(s))
637            }
638        })
639    }
640
641    /// A throwaway keypair + scratch dir, all generated via the `gpg`
642    /// CLI. Built once per test that needs it (cheap — RSA-1024 batch
643    /// generation takes well under a second).
644    struct Fixture {
645        gpg: PathBuf,
646        home: PathBuf,
647        _tmp: TempDir,
648        keyid: String,
649    }
650
651    impl Fixture {
652        fn new() -> Option<Self> {
653            let gpg = gpg_path()?;
654            let tmp = tempfile::tempdir().ok()?;
655            let home = tmp.path().join("gpghome");
656            fs::create_dir_all(&home).ok()?;
657            #[cfg(unix)]
658            {
659                use std::os::unix::fs::PermissionsExt;
660                fs::set_permissions(&home, fs::Permissions::from_mode(0o700)).ok()?;
661            }
662            let home_arg = format!("{}", home.display());
663
664            // Batch-generate a key without user interaction.
665            let batch = r#"Key-Type: RSA
666Key-Length: 1024
667Name-Real: Confium Test Publisher
668Name-Email: test@confium.example
669Expire-Date: 0
670%no-protection
671%commit
672"#;
673            let batch_path = tmp.path().join("batch");
674            fs::write(&batch_path, batch).ok()?;
675
676            let gen_out = Command::new(&gpg)
677                .args(["--homedir", &home_arg, "--batch", "--gen-key"])
678                .arg(&batch_path)
679                .output()
680                .ok()?;
681            if !gen_out.status.success() {
682                return None;
683            }
684
685            // Find the keyid of the freshly generated key.
686            let list = Command::new(&gpg)
687                .args(["--homedir", &home_arg, "--list-keys", "--with-colons"])
688                .output()
689                .ok()?;
690            if !list.status.success() {
691                return None;
692            }
693            let stdout = String::from_utf8_lossy(&list.stdout);
694            let keyid = stdout.lines().find_map(|line| {
695                let mut fields = line.split(':');
696                if fields.next() == Some("pub") {
697                    // pub:o:2048:1:<keyid>:<created>:<expires>:::e::esc
698                    fields.nth(3).map(|s| s.to_string())
699                } else {
700                    None
701                }
702            })?;
703
704            Some(Fixture {
705                gpg,
706                home,
707                _tmp: tmp,
708                keyid,
709            })
710        }
711
712        fn home_arg(&self) -> String {
713            format!("{}", self.home.display())
714        }
715
716        /// Export the public key (ASCII-armored) as bytes.
717        fn export_pubkey(&self) -> Vec<u8> {
718            let out = Command::new(&self.gpg)
719                .args([
720                    "--homedir",
721                    &self.home_arg(),
722                    "--armor",
723                    "--export",
724                    &self.keyid,
725                ])
726                .output()
727                .expect("gpg --export");
728            assert!(out.status.success(), "gpg export failed");
729            out.stdout
730        }
731
732        /// Sign `data` with the test secret key, returning the detached
733        /// ASCII-armored signature.
734        fn sign_detached(&self, data: &[u8]) -> Vec<u8> {
735            let tmp = tempfile::tempdir().expect("tempdir");
736            let data_path = tmp.path().join("data.bin");
737            fs::write(&data_path, data).expect("write data");
738            let out = Command::new(&self.gpg)
739                .args([
740                    "--homedir",
741                    &self.home_arg(),
742                    "--batch",
743                    "--yes",
744                    "--detach-sign",
745                    "--armor",
746                ])
747                .arg(&data_path)
748                .output()
749                .expect("gpg --detach-sign");
750            assert!(out.status.success(), "gpg sign failed");
751            let sig_path = tmp.path().join("data.bin.asc");
752            fs::read(&sig_path).expect("read sig")
753        }
754    }
755
756    #[test]
757    fn verify_signature_accepts_valid_signature() {
758        let f = match Fixture::new() {
759            Some(f) => f,
760            None => {
761                eprintln!("skipping: gpg not available");
762                return;
763            }
764        };
765        let pubkey = f.export_pubkey();
766        let artifact = b"the quick brown fox jumps over the lazy dog";
767        let sig = f.sign_detached(artifact);
768        assert!(verify_signature(artifact, &sig, &pubkey).is_ok());
769    }
770
771    #[test]
772    fn verify_signature_rejects_tampered_artifact() {
773        let f = match Fixture::new() {
774            Some(f) => f,
775            None => {
776                eprintln!("skipping: gpg not available");
777                return;
778            }
779        };
780        let pubkey = f.export_pubkey();
781        let sig = f.sign_detached(b"original artifact bytes");
782        let tampered = b"modified artifact bytes";
783        let err = verify_signature(tampered, &sig, &pubkey).unwrap_err();
784        assert!(
785            matches!(
786                err,
787                Error::SignatureInvalid { .. } | Error::RnpVerify { .. }
788            ),
789            "unexpected error: {err:?}"
790        );
791    }
792
793    #[test]
794    fn verify_signature_rejects_wrong_pubkey() {
795        let f = match Fixture::new() {
796            Some(f) => f,
797            None => {
798                eprintln!("skipping: gpg not available");
799                return;
800            }
801        };
802        // Sign with one key, verify against another.
803        let artifact = b"some artifact";
804        let sig = f.sign_detached(artifact);
805        // Build a second fixture for an unrelated key.
806        let other = Fixture::new().expect("second fixture");
807        let wrong_pubkey = other.export_pubkey();
808        let err = verify_signature(artifact, &sig, &wrong_pubkey).unwrap_err();
809        assert!(
810            matches!(
811                err,
812                Error::SignatureInvalid { .. } | Error::RnpVerify { .. }
813            ),
814            "unexpected error: {err:?}"
815        );
816    }
817
818    #[test]
819    fn verify_signature_rejects_garbage_signature() {
820        let f = match Fixture::new() {
821            Some(f) => f,
822            None => {
823                eprintln!("skipping: gpg not available");
824                return;
825            }
826        };
827        let pubkey = f.export_pubkey();
828        let garbage = b"not a real signature";
829        let err = verify_signature(b"artifact", garbage, &pubkey).unwrap_err();
830        // Either RNP fails outright or reports an invalid signature.
831        assert!(
832            matches!(
833                err,
834                Error::SignatureInvalid { .. }
835                    | Error::RnpVerify { .. }
836                    | Error::SignatureFormat { .. }
837            ),
838            "unexpected error: {err:?}"
839        );
840    }
841
842    /// Sanity: `gpg --verify` fallback path works on its own.
843    #[test]
844    fn gpg_fallback_accepts_valid_signature() {
845        let f = match Fixture::new() {
846            Some(f) => f,
847            None => {
848                eprintln!("skipping: gpg not available");
849                return;
850            }
851        };
852        let pubkey = f.export_pubkey();
853        let artifact = b"artifact bytes";
854        let sig = f.sign_detached(artifact);
855        assert!(verify_via_gpg(artifact, &sig, &pubkey).is_ok());
856    }
857}
858
859// `which` is dev-only; pull it in conditionally without polluting the
860// main Cargo.toml dependencies.
861#[cfg(test)]
862mod which_shim {
863    /// Resolve an executable name on `$PATH` without pulling a crate.
864    pub fn which(name: &str) -> Option<std::path::PathBuf> {
865        let path = std::env::var_os("PATH")?;
866        for dir in std::env::split_paths(&path) {
867            let candidate = dir.join(name);
868            if candidate.is_file() {
869                return Some(candidate);
870            }
871        }
872        None
873    }
874}