Skip to main content

entracte_lib/plugins/
signature.rs

1//! Manifest signature verification. Pure — no I/O.
2//!
3//! Signing protects integrity and provenance, not authorization (that's the
4//! consent dialog, a later slice). A valid signature means "this manifest
5//! and module are intact and were produced by the holder of this key."
6//!
7//! The signed payload is `canonical(manifest-without-signature) ‖
8//! module_hash`, so the signature binds the wasm module's bytes, not just
9//! the metadata — a swapped module fails verification even with an
10//! untouched manifest. Content plugins carry no module, so their payload
11//! omits the hash. Canonicalisation is `serde_json` over the manifest value
12//! with the `signature` key removed; serde_json's default `Map` is
13//! key-ordered, so the bytes are reproducible by the signing tool.
14
15use base64::prelude::{Engine, BASE64_STANDARD};
16use ed25519_dalek::{Signature as DalekSignature, VerifyingKey};
17use sha2::{Digest, Sha256};
18
19use super::manifest::Manifest;
20
21/// SHA-256 of `bytes`, as a fixed 32-byte array. Used to hash a plugin's
22/// wasm module for inclusion in the signed payload (content plugins sign over
23/// the manifest alone).
24pub fn sha256(bytes: &[u8]) -> [u8; 32] {
25    let mut hasher = Sha256::new();
26    hasher.update(bytes);
27    hasher.finalize().into()
28}
29
30/// The exact bytes a manifest's signature is computed over: the manifest
31/// serialised to JSON with its `signature` field removed, followed by the
32/// module hash (when the plugin ships one). Pure and deterministic, so both
33/// the signer and the verifier produce identical input.
34pub fn signing_payload(manifest: &Manifest, module_sha256: Option<[u8; 32]>) -> Vec<u8> {
35    let mut value = serde_json::to_value(manifest).expect("manifest is always serialisable");
36    let obj = value
37        .as_object_mut()
38        .expect("a manifest always serialises to a JSON object");
39    obj.remove("signature");
40    // The module bytes are bound by their hash (appended below), not by the
41    // base64 blob in the JSON — so exclude it from the canonical payload.
42    obj.remove("module_base64");
43    // Image assets are bound the same way, but by the `sha256` each declares
44    // (which stays in the canonical manifest): strip only the heavy blob so a
45    // megabyte of base64 never goes through the signer, while the hash — and
46    // thus the bytes, since the installer verifies they match — stays signed.
47    if let Some(assets) = obj.get_mut("assets").and_then(|a| a.as_array_mut()) {
48        for asset in assets.iter_mut().filter_map(|a| a.as_object_mut()) {
49            asset.remove("data_base64");
50        }
51    }
52    let mut bytes = serde_json::to_vec(&value).expect("json value is always serialisable");
53    if let Some(hash) = module_sha256 {
54        bytes.extend_from_slice(&hash);
55    }
56    bytes
57}
58
59/// Verify a manifest's ed25519 signature over [`signing_payload`]. Pass the
60/// module's [`sha256`] for code-bearing plugins, `None` for content
61/// plugins. Returns a user-facing error string on any failure (wrong alg,
62/// malformed key/signature, or a verification mismatch) — never panics.
63pub fn verify_signature(
64    manifest: &Manifest,
65    module_sha256: Option<[u8; 32]>,
66) -> Result<(), String> {
67    if manifest.signature.alg != "ed25519" {
68        return Err(format!(
69            "unsupported signature algorithm '{}' (expected ed25519)",
70            manifest.signature.alg
71        ));
72    }
73
74    let key_bytes: [u8; 32] = BASE64_STANDARD
75        .decode(manifest.signature.public_key.as_bytes())
76        .map_err(|_| "signature public_key is not valid base64".to_string())?
77        .try_into()
78        .map_err(|_| "signature public_key is not a 32-byte ed25519 key".to_string())?;
79    let verifying_key = VerifyingKey::from_bytes(&key_bytes)
80        .map_err(|_| "signature public_key is not a valid ed25519 key".to_string())?;
81
82    let sig_bytes: [u8; 64] = BASE64_STANDARD
83        .decode(manifest.signature.sig.as_bytes())
84        .map_err(|_| "signature sig is not valid base64".to_string())?
85        .try_into()
86        .map_err(|_| "signature sig is not a 64-byte ed25519 signature".to_string())?;
87    let signature = DalekSignature::from_bytes(&sig_bytes);
88
89    let payload = signing_payload(manifest, module_sha256);
90    verifying_key
91        .verify_strict(&payload, &signature)
92        .map_err(|_| "signature does not match the manifest (tampered or wrong key)".to_string())
93}
94
95#[cfg(test)]
96mod tests {
97    use super::*;
98    use crate::plugins::manifest::{
99        PluginKind, Signature, MANIFEST_VERSION, SUPPORTED_ABI_VERSION,
100    };
101    use ed25519_dalek::{Signer, SigningKey};
102
103    /// A deterministic keypair from a fixed seed — no RNG dependency, and
104    /// reproducible across test runs.
105    fn keypair(seed: u8) -> SigningKey {
106        SigningKey::from_bytes(&[seed; 32])
107    }
108
109    fn unsigned_detector() -> Manifest {
110        Manifest {
111            manifest_version: MANIFEST_VERSION,
112            id: "com.example.focus".to_string(),
113            name: "Focus detector".to_string(),
114            version: "1.0.0".to_string(),
115            author: "Jane".to_string(),
116            description: String::new(),
117            kind: PluginKind::Detector,
118            module: Some("module.wasm".to_string()),
119            module_base64: None,
120            abi_version: Some(SUPPORTED_ABI_VERSION),
121            imports: vec!["detect:foreground-window".to_string()],
122            detect: None,
123            export: None,
124            content: None,
125            assets: Vec::new(),
126            signature: Signature {
127                alg: "ed25519".to_string(),
128                public_key: String::new(),
129                sig: String::new(),
130            },
131        }
132    }
133
134    /// Sign `manifest` in place with `key` over its current payload.
135    fn sign(manifest: &mut Manifest, key: &SigningKey, module_sha256: Option<[u8; 32]>) {
136        manifest.signature.public_key = BASE64_STANDARD.encode(key.verifying_key().to_bytes());
137        let payload = signing_payload(manifest, module_sha256);
138        let sig = key.sign(&payload);
139        manifest.signature.sig = BASE64_STANDARD.encode(sig.to_bytes());
140    }
141
142    #[test]
143    fn sha256_is_stable_and_32_bytes() {
144        let a = sha256(b"hello");
145        let b = sha256(b"hello");
146        assert_eq!(a, b);
147        assert_eq!(a.len(), 32);
148        assert_ne!(sha256(b"hello"), sha256(b"world"));
149    }
150
151    #[test]
152    fn verifies_a_correctly_signed_manifest() {
153        let module = b"\0asm fake module";
154        let hash = sha256(module);
155        let key = keypair(7);
156        let mut m = unsigned_detector();
157        sign(&mut m, &key, Some(hash));
158        assert!(verify_signature(&m, Some(hash)).is_ok());
159    }
160
161    #[test]
162    fn rejects_a_tampered_manifest_field() {
163        let module = b"\0asm fake module";
164        let hash = sha256(module);
165        let key = keypair(7);
166        let mut m = unsigned_detector();
167        sign(&mut m, &key, Some(hash));
168        // Mutate a signed field after signing.
169        m.name = "Evil detector".to_string();
170        assert!(verify_signature(&m, Some(hash))
171            .unwrap_err()
172            .contains("does not match"));
173    }
174
175    #[test]
176    fn rejects_a_swapped_module() {
177        let key = keypair(7);
178        let mut m = unsigned_detector();
179        let original = sha256(b"\0asm original");
180        sign(&mut m, &key, Some(original));
181        // Same manifest, different module bytes ⇒ different hash ⇒ fail.
182        let swapped = sha256(b"\0asm malicious");
183        assert!(verify_signature(&m, Some(swapped))
184            .unwrap_err()
185            .contains("does not match"));
186    }
187
188    #[test]
189    fn rejects_a_wrong_key() {
190        let module = b"\0asm fake module";
191        let hash = sha256(module);
192        let mut m = unsigned_detector();
193        sign(&mut m, &keypair(1), Some(hash));
194        // Re-point the public key at a different keypair without re-signing.
195        m.signature.public_key = BASE64_STANDARD.encode(keypair(2).verifying_key().to_bytes());
196        assert!(verify_signature(&m, Some(hash)).is_err());
197    }
198
199    #[test]
200    fn rejects_non_ed25519_alg() {
201        let mut m = unsigned_detector();
202        m.signature.alg = "rsa".to_string();
203        assert!(verify_signature(&m, None)
204            .unwrap_err()
205            .contains("unsupported signature algorithm"));
206    }
207
208    #[test]
209    fn rejects_malformed_key_and_sig() {
210        let mut m = unsigned_detector();
211        m.signature.public_key = "not base64!!!".to_string();
212        m.signature.sig = "AA==".to_string();
213        assert!(verify_signature(&m, None)
214            .unwrap_err()
215            .contains("public_key is not valid base64"));
216
217        let mut m = unsigned_detector();
218        m.signature.public_key = BASE64_STANDARD.encode([0u8; 32]);
219        m.signature.sig = BASE64_STANDARD.encode([0u8; 10]); // wrong length
220        assert!(verify_signature(&m, None)
221            .unwrap_err()
222            .contains("64-byte ed25519 signature"));
223    }
224
225    #[test]
226    fn rejects_public_key_of_wrong_length() {
227        // Valid base64 but decodes to fewer than 32 bytes.
228        let mut m = unsigned_detector();
229        m.signature.public_key = BASE64_STANDARD.encode([0u8; 10]);
230        m.signature.sig = BASE64_STANDARD.encode([0u8; 64]);
231        assert!(verify_signature(&m, None)
232            .unwrap_err()
233            .contains("not a 32-byte ed25519 key"));
234    }
235
236    #[test]
237    fn rejects_a_32_byte_value_that_is_not_a_valid_curve_point() {
238        use ed25519_dalek::VerifyingKey;
239        // Decodes to 32 bytes (so the length check passes) but is not a
240        // valid compressed Edwards point, so VerifyingKey::from_bytes fails.
241        // Search deterministically for such an encoding — not every 32-byte
242        // value decompresses (e.g. [0xFF; 32] happens to), so pick one that
243        // genuinely doesn't.
244        let invalid = (0u8..=255)
245            .map(|b| [b; 32])
246            .find(|bytes| VerifyingKey::from_bytes(bytes).is_err())
247            .expect("some [b; 32] is not a valid curve point");
248        let mut m = unsigned_detector();
249        m.signature.public_key = BASE64_STANDARD.encode(invalid);
250        m.signature.sig = BASE64_STANDARD.encode([0u8; 64]);
251        assert!(verify_signature(&m, None)
252            .unwrap_err()
253            .contains("not a valid ed25519 key"));
254    }
255
256    #[test]
257    fn rejects_sig_that_is_not_valid_base64() {
258        let mut m = unsigned_detector();
259        m.signature.public_key = BASE64_STANDARD.encode([0u8; 32]);
260        m.signature.sig = "not base64!!!".to_string();
261        assert!(verify_signature(&m, None)
262            .unwrap_err()
263            .contains("sig is not valid base64"));
264    }
265
266    #[test]
267    fn content_plugin_signs_without_a_module_hash() {
268        let key = keypair(9);
269        let mut m = unsigned_detector();
270        m.kind = PluginKind::Content;
271        m.module = None;
272        m.abi_version = None;
273        m.imports = vec![];
274        sign(&mut m, &key, None);
275        assert!(verify_signature(&m, None).is_ok());
276        // A content plugin verified as if it had a module must fail.
277        assert!(verify_signature(&m, Some(sha256(b"x"))).is_err());
278    }
279
280    fn content_with_asset() -> Manifest {
281        use crate::plugins::asset::ManifestAsset;
282        let mut m = unsigned_detector();
283        m.kind = PluginKind::Content;
284        m.module = Some("module.wasm".to_string());
285        m.abi_version = None;
286        m.imports = vec![];
287        let bytes = b"\x89PNG\r\n\x1a\n fake image bytes";
288        let mut hasher = Sha256::new();
289        hasher.update(bytes);
290        let hash: [u8; 32] = hasher.finalize().into();
291        m.assets = vec![ManifestAsset {
292            id: "twist".to_string(),
293            sha256: hash.iter().map(|b| format!("{b:02x}")).collect(),
294            data_base64: BASE64_STANDARD.encode(bytes),
295        }];
296        m
297    }
298
299    #[test]
300    fn asset_blob_is_excluded_from_the_signed_payload() {
301        // The heavy base64 must not reach the signer: swapping only the blob
302        // (keeping the declared sha256) leaves the payload — and signature —
303        // unchanged. Integrity of the bytes is enforced separately by
304        // validate_asset, not by the signature.
305        let mut m = content_with_asset();
306        let before = signing_payload(&m, None);
307        m.assets[0].data_base64 = BASE64_STANDARD.encode(b"totally different bytes");
308        let after = signing_payload(&m, None);
309        assert_eq!(before, after);
310    }
311
312    #[test]
313    fn asset_hash_is_inside_the_signed_payload() {
314        // The declared sha256 stays signed: changing it after signing breaks
315        // verification (so an attacker can't redirect an asset to other bytes).
316        let key = keypair(11);
317        let mut m = content_with_asset();
318        sign(&mut m, &key, None);
319        assert!(verify_signature(&m, None).is_ok());
320        m.assets[0].sha256 = "00".repeat(32);
321        assert!(verify_signature(&m, None)
322            .unwrap_err()
323            .contains("does not match"));
324    }
325}