Skip to main content

waf_wasm_signing/
lib.rs

1// SPDX-FileCopyrightText: 2026 0x00spor3
2// SPDX-License-Identifier: Apache-2.0
3
4//! Signed WASM plugins for [Light WAF](https://github.com/0x00spor3/Light-WAF).
5//!
6//! The core's Proxy-Wasm runtime (`waf-wasm`) loads unsigned plugins. This crate adds a trust gate:
7//! [`verify_signature`] checks a detached **minisign** (Ed25519) signature over the exact `.wasm` bytes
8//! against trusted public keys, and [`load_verified_plugin`] hands those same bytes to the runtime only
9//! if it verifies (no TOCTOU).
10//!
11//! **Fail-closed on purpose.** The core loader skips a broken unsigned plugin (a missing plugin only
12//! reduces coverage). A signed plugin whose signature does not verify is indistinguishable from
13//! tampering, so it is an error, never a silent skip.
14
15use std::io::Cursor;
16
17use minisign::{PublicKey, SignatureBox};
18use waf_wasm::WasmModule;
19
20// Re-export so the enterprise wiring builds runtime options without depending on `waf-wasm` directly.
21pub use waf_wasm::WasmOptions;
22
23/// Verify a detached minisign `signature` (the `.minisig` text) over `wasm`, accepting it if it
24/// verifies against ANY of the base64 `trusted_pubkeys` (the key line of a minisign `.pub`).
25/// Fail-closed: unknown key, malformed signature, or tampered bytes all return `Err`.
26pub fn verify_signature(
27    wasm: &[u8],
28    signature: &str,
29    trusted_pubkeys: &[String],
30) -> Result<(), String> {
31    let sig_box =
32        SignatureBox::from_string(signature).map_err(|e| format!("malformed signature: {e}"))?;
33    if trusted_pubkeys.is_empty() {
34        return Err("no trusted_keys configured".to_string());
35    }
36    for key_b64 in trusted_pubkeys {
37        let Ok(pk) = PublicKey::from_base64(key_b64) else {
38            continue; // a malformed configured key can't be the one that trusts this plugin
39        };
40        if minisign::verify(&pk, &sig_box, Cursor::new(wasm), true, false, false).is_ok() {
41            return Ok(());
42        }
43    }
44    Err("signature does not verify against any trusted key".to_string())
45}
46
47/// Verify the plugin's signature and, ONLY if it verifies, delegate to the OPEN
48/// `WasmModule::from_bytes`. The **same `wasm` buffer** is verified and loaded (no TOCTOU, K4). A
49/// signature failure returns before the runtime ever compiles the bytes (fail-closed gate, K5).
50pub fn load_verified_plugin(
51    name: &str,
52    wasm: &[u8],
53    signature: &str,
54    trusted_pubkeys: &[String],
55    config: &[u8],
56    opts: &WasmOptions,
57) -> Result<WasmModule, String> {
58    verify_signature(wasm, signature, trusted_pubkeys)?;
59    let (module, _report) = WasmModule::from_bytes(name, wasm, config, opts)
60        .map_err(|e| format!("verified plugin {name:?} failed to load: {e}"))?;
61    Ok(module)
62}
63
64#[cfg(test)]
65mod tests {
66    use super::*;
67    use minisign::KeyPair;
68
69    /// Sign `data`, returning `(minisig_text, pubkey_base64)`.
70    fn sign(data: &[u8]) -> (String, String) {
71        let kp = KeyPair::generate_unencrypted_keypair().unwrap();
72        let sig_box = minisign::sign(None, &kp.sk, Cursor::new(data), None, None).unwrap();
73        (sig_box.into_string(), kp.pk.to_base64())
74    }
75
76    const WASM: &[u8] = b"\x00asm\x01\x00\x00\x00 pretend this is a plugin";
77
78    #[test]
79    fn valid_signature_verifies() {
80        let (sig, pk) = sign(WASM);
81        assert!(verify_signature(WASM, &sig, &[pk]).is_ok());
82    }
83
84    #[test]
85    fn tampered_bytes_fail() {
86        let (sig, pk) = sign(WASM);
87        let mut tampered = WASM.to_vec();
88        tampered[10] ^= 0xff;
89        assert!(verify_signature(&tampered, &sig, &[pk]).is_err());
90    }
91
92    #[test]
93    fn wrong_key_fails() {
94        let (sig, _pk) = sign(WASM);
95        let (_sig2, other_pk) = sign(WASM); // a different keypair
96        assert!(verify_signature(WASM, &sig, &[other_pk]).is_err());
97    }
98
99    #[test]
100    fn malformed_signature_fails() {
101        assert!(verify_signature(WASM, "not a minisig", &["x".to_string()]).is_err());
102    }
103
104    #[test]
105    fn no_trusted_keys_fails() {
106        let (sig, _pk) = sign(WASM);
107        assert!(verify_signature(WASM, &sig, &[]).is_err());
108    }
109
110    #[test]
111    fn verifies_against_any_of_several_keys() {
112        let (sig, pk) = sign(WASM);
113        let (_s, other) = sign(WASM);
114        // The trusted set contains an unrelated key AND the right one → accepted.
115        assert!(verify_signature(WASM, &sig, &[other, pk]).is_ok());
116    }
117
118    fn opts() -> WasmOptions {
119        WasmOptions::default()
120    }
121
122    #[test]
123    fn load_gate_rejects_bad_signature_before_touching_the_runtime() {
124        // A real (minimal) wasm module, but an UNTRUSTED signature → must fail at the gate.
125        let wasm = wat::parse_str("(module)").unwrap();
126        let (sig, _pk) = sign(&wasm);
127        let (_s, other) = sign(&wasm); // sign is fine; the key is not trusted
128        match load_verified_plugin("p", &wasm, &sig, &[other], b"", &opts()) {
129            Ok(_) => panic!("an untrusted signature must not load"),
130            Err(e) => assert!(e.contains("verify") || e.contains("trusted"), "gate error, got: {e}"),
131        }
132    }
133
134    #[test]
135    fn load_with_valid_signature_reaches_the_runtime() {
136        // Same wasm, TRUSTED signature → the gate passes and control reaches `from_bytes`. Whatever
137        // the runtime decides about a bare `(module)`, the error (if any) is NOT a signature error.
138        let wasm = wat::parse_str("(module)").unwrap();
139        let (sig, pk) = sign(&wasm);
140        match load_verified_plugin("p", &wasm, &sig, &[pk], b"", &opts()) {
141            Ok(_) => {} // loaded
142            Err(e) => assert!(
143                !e.contains("verify") && !e.contains("trusted"),
144                "must fail (if at all) at the runtime, not the gate: {e}"
145            ),
146        }
147    }
148}