entracte_lib/plugins/
signature.rs1use base64::prelude::{Engine, BASE64_STANDARD};
16use ed25519_dalek::{Signature as DalekSignature, VerifyingKey};
17use sha2::{Digest, Sha256};
18
19use super::manifest::Manifest;
20
21pub fn sha256(bytes: &[u8]) -> [u8; 32] {
25 let mut hasher = Sha256::new();
26 hasher.update(bytes);
27 hasher.finalize().into()
28}
29
30pub 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 obj.remove("module_base64");
43 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
59pub 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 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 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 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 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 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]); 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 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 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 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 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 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}