1use crate::errors::PluginError;
17use crate::manifest::PluginManifest;
18
19#[cfg(test)]
20use crate::manifest::ManifestSignature;
21
22pub fn verify_hash_pin(manifest: &PluginManifest, payload: &[u8]) -> Result<(), PluginError> {
33 let Some(expected_hex) = manifest.hash.as_ref() else {
34 return Ok(());
35 };
36 let actual = blake3::hash(payload);
37 let actual_hex = actual.to_hex().to_string();
38 if !constant_time_eq(expected_hex, &actual_hex) {
39 return Err(PluginError::HashMismatch {
40 expected: expected_hex.clone(),
41 actual: actual_hex,
42 });
43 }
44 Ok(())
45}
46
47pub fn verify_signed_manifest(
66 manifest: &PluginManifest,
67 trust_root: &TrustRoot,
68) -> Result<(), PluginError> {
69 let Some(sig) = manifest.signature.as_ref() else {
70 return Ok(());
74 };
75 if sig.algorithm != "ed25519" {
78 return Err(PluginError::SignatureInvalid(format!(
79 "unsupported algorithm `{}`",
80 sig.algorithm
81 )));
82 }
83 if !trust_root.contains(&sig.key_id) {
84 return Err(PluginError::SignatureInvalid(format!(
85 "key `{}` not in trust root",
86 sig.key_id
87 )));
88 }
89 let public_key_bytes = trust_root.public_key(&sig.key_id).ok_or_else(|| {
90 PluginError::SignatureInvalid(format!(
91 "trust root for key `{}` has no public key bytes",
92 sig.key_id
93 ))
94 })?;
95 let signing_payload = canonical_payload(manifest)?;
96 verify_ed25519(public_key_bytes, &signing_payload, &sig.value)
97}
98
99const MANIFEST_SIG_DOMAIN_V1: &[u8] = b"uni-plugin-manifest-sig:v1\0";
107
108fn canonical_payload(manifest: &PluginManifest) -> Result<Vec<u8>, PluginError> {
129 let mut unsigned = manifest.clone();
130 unsigned.signature = None;
131 let value = serde_json::to_value(&unsigned).map_err(|e| {
132 PluginError::SignatureInvalid(format!("manifest canonicalization failed: {e}"))
133 })?;
134 let json = serde_json::to_vec(&value).map_err(|e| {
135 PluginError::SignatureInvalid(format!("manifest canonicalization failed: {e}"))
136 })?;
137 let mut bytes = Vec::with_capacity(MANIFEST_SIG_DOMAIN_V1.len() + json.len());
138 bytes.extend_from_slice(MANIFEST_SIG_DOMAIN_V1);
139 bytes.extend_from_slice(&json);
140 Ok(bytes)
141}
142
143fn verify_ed25519(
144 public_key_bytes: &[u8; 32],
145 payload: &[u8],
146 signature_b64: &str,
147) -> Result<(), PluginError> {
148 use base64::Engine;
149 use ed25519_dalek::{Signature, Verifier, VerifyingKey};
150
151 let key = VerifyingKey::from_bytes(public_key_bytes)
152 .map_err(|e| PluginError::SignatureInvalid(format!("malformed ed25519 public key: {e}")))?;
153 let sig_bytes = base64::engine::general_purpose::STANDARD
154 .decode(signature_b64.as_bytes())
155 .map_err(|e| PluginError::SignatureInvalid(format!("signature base64: {e}")))?;
156 let sig = Signature::from_slice(&sig_bytes)
157 .map_err(|e| PluginError::SignatureInvalid(format!("signature parse: {e}")))?;
158 key.verify(payload, &sig)
159 .map_err(|e| PluginError::SignatureInvalid(format!("ed25519 verify failed: {e}")))?;
160 Ok(())
161}
162
163#[derive(Debug, Default)]
167pub struct TrustRoot {
168 allowed_keys: std::collections::BTreeMap<String, Option<[u8; 32]>>,
172}
173
174impl TrustRoot {
175 #[must_use]
177 pub fn new() -> Self {
178 Self::default()
179 }
180
181 pub fn allow(&mut self, key_id: impl Into<String>) {
186 self.allowed_keys.insert(key_id.into(), None);
187 }
188
189 pub fn allow_with_key(&mut self, key_id: impl Into<String>, public_key: [u8; 32]) {
191 self.allowed_keys.insert(key_id.into(), Some(public_key));
192 }
193
194 #[must_use]
196 pub fn contains(&self, key_id: &str) -> bool {
197 self.allowed_keys.contains_key(key_id)
198 }
199
200 #[must_use]
202 pub fn public_key(&self, key_id: &str) -> Option<&[u8; 32]> {
203 self.allowed_keys.get(key_id).and_then(|k| k.as_ref())
204 }
205}
206
207#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
213pub enum SignaturePolicy {
214 #[default]
217 Disabled,
218 WarnIfUnsigned,
220 RequireSigned,
222}
223
224pub fn verify_manifest_with_policy(
237 manifest: &PluginManifest,
238 trust_root: &TrustRoot,
239 policy: SignaturePolicy,
240) -> Result<(), PluginError> {
241 match policy {
242 SignaturePolicy::Disabled => Ok(()),
243 SignaturePolicy::WarnIfUnsigned => {
244 if manifest.signature.is_none() {
245 tracing::warn!(
246 plugin_id = %manifest.id.as_str(),
247 "plugin manifest has no signature; accepted under WarnIfUnsigned policy",
248 );
249 }
250 verify_signed_manifest(manifest, trust_root)
251 }
252 SignaturePolicy::RequireSigned => {
253 if manifest.signature.is_none() {
254 return Err(PluginError::SignatureInvalid(format!(
255 "plugin `{}` has no manifest signature; RequireSigned policy rejects it",
256 manifest.id.as_str()
257 )));
258 }
259 verify_signed_manifest(manifest, trust_root)
260 }
261 }
262}
263
264fn constant_time_eq(a: &str, b: &str) -> bool {
270 if a.len() != b.len() {
271 return false;
272 }
273 let mut diff: u8 = 0;
274 for (ai, bi) in a.bytes().zip(b.bytes()) {
275 diff |= ai ^ bi;
276 }
277 diff == 0
278}
279
280#[cfg(test)]
281mod tests {
282 use super::*;
283 use crate::manifest::AbiRange;
284 use crate::plugin::PluginId;
285 use crate::{Determinism, Scope, SideEffects};
286 use semver::Version;
287
288 fn empty_manifest() -> PluginManifest {
289 PluginManifest {
290 id: PluginId::new("test"),
291 version: Version::new(0, 1, 0),
292 abi: AbiRange::parse("^1").unwrap(),
293 depends_on: vec![],
294 capabilities: crate::CapabilitySet::new(),
295 determinism: Determinism::Pure,
296 side_effects: SideEffects::ReadOnly,
297 scope: Scope::Instance,
298 hash: None,
299 signature: None,
300 provides: crate::ProvidedSurfaces::default(),
301 docs: String::new(),
302 metadata: std::collections::BTreeMap::new(),
303 }
304 }
305
306 #[test]
307 fn hash_pin_passes_when_unpinned() {
308 let m = empty_manifest();
309 assert!(verify_hash_pin(&m, b"anything").is_ok());
310 }
311
312 #[test]
313 fn hash_pin_passes_with_correct_hash() {
314 let mut m = empty_manifest();
315 let payload = b"hello world";
316 m.hash = Some(blake3::hash(payload).to_hex().to_string());
317 assert!(verify_hash_pin(&m, payload).is_ok());
318 }
319
320 #[test]
321 fn hash_pin_fails_with_wrong_hash() {
322 let mut m = empty_manifest();
323 m.hash = Some(blake3::hash(b"a").to_hex().to_string());
324 match verify_hash_pin(&m, b"b") {
325 Err(PluginError::HashMismatch { expected, actual }) => {
326 assert!(!expected.is_empty());
327 assert!(!actual.is_empty());
328 assert_ne!(expected, actual);
329 }
330 other => panic!("expected HashMismatch, got {other:?}"),
331 }
332 }
333
334 #[test]
335 fn signature_verification_rejects_unknown_key_id() {
336 let mut m = empty_manifest();
337 m.signature = Some(ManifestSignature {
338 algorithm: "ed25519".to_owned(),
339 key_id: "ops@example.com".to_owned(),
340 value: "base64...".to_owned(),
341 });
342 let tr = TrustRoot::new();
343 assert!(verify_signed_manifest(&m, &tr).is_err());
344 }
345
346 #[test]
353 fn verify_signed_manifest_real_ed25519_round_trip() {
354 use base64::Engine;
355 use ed25519_dalek::{Signer, SigningKey};
356
357 let seed: [u8; 32] = [
358 0x9d, 0x61, 0xb1, 0x9d, 0xef, 0xfd, 0x5a, 0x60, 0xba, 0x84, 0x4a, 0xf4, 0x92, 0xec,
359 0x2c, 0xc4, 0x44, 0x49, 0xc5, 0x69, 0x7b, 0x32, 0x69, 0x19, 0x70, 0x3b, 0xac, 0x03,
360 0x1c, 0xae, 0x7f, 0x60,
361 ];
362 let signing_key = SigningKey::from_bytes(&seed);
363 let public_key_bytes: [u8; 32] = signing_key.verifying_key().to_bytes();
364
365 let mut m = empty_manifest();
366 m.hash = Some(blake3::hash(b"plugin payload").to_hex().to_string());
367
368 let payload = canonical_payload(&m).expect("canonicalize");
369 let sig = signing_key.sign(&payload);
370 let sig_b64 = base64::engine::general_purpose::STANDARD.encode(sig.to_bytes());
371
372 m.signature = Some(ManifestSignature {
373 algorithm: "ed25519".to_owned(),
374 key_id: "ops@example.com".to_owned(),
375 value: sig_b64,
376 });
377
378 let mut tr = TrustRoot::new();
379 tr.allow_with_key("ops@example.com", public_key_bytes);
380
381 verify_signed_manifest(&m, &tr).expect("real Ed25519 verify must succeed");
384
385 m.hash = Some(blake3::hash(b"different payload").to_hex().to_string());
387 assert!(
388 verify_signed_manifest(&m, &tr).is_err(),
389 "tampered manifest must fail verification"
390 );
391 }
392
393 #[test]
398 fn verify_rejects_capability_substitution() {
399 use base64::Engine;
400 use ed25519_dalek::{Signer, SigningKey};
401
402 let seed: [u8; 32] = [
403 0x9d, 0x61, 0xb1, 0x9d, 0xef, 0xfd, 0x5a, 0x60, 0xba, 0x84, 0x4a, 0xf4, 0x92, 0xec,
404 0x2c, 0xc4, 0x44, 0x49, 0xc5, 0x69, 0x7b, 0x32, 0x69, 0x19, 0x70, 0x3b, 0xac, 0x03,
405 0x1c, 0xae, 0x7f, 0x60,
406 ];
407 let signing_key = SigningKey::from_bytes(&seed);
408 let public_key_bytes: [u8; 32] = signing_key.verifying_key().to_bytes();
409
410 let mut m = empty_manifest();
412 m.hash = Some(blake3::hash(b"plugin payload").to_hex().to_string());
413 let payload = canonical_payload(&m).expect("canonicalize");
414 let sig_b64 =
415 base64::engine::general_purpose::STANDARD.encode(signing_key.sign(&payload).to_bytes());
416 m.signature = Some(ManifestSignature {
417 algorithm: "ed25519".to_owned(),
418 key_id: "ops@example.com".to_owned(),
419 value: sig_b64,
420 });
421
422 let mut tr = TrustRoot::new();
423 tr.allow_with_key("ops@example.com", public_key_bytes);
424 verify_signed_manifest(&m, &tr).expect("baseline signed manifest must verify");
425
426 m.capabilities.insert(crate::Capability::ProcedureWrites);
429 m.side_effects = SideEffects::Writes;
430 assert!(
431 verify_signed_manifest(&m, &tr).is_err(),
432 "capability substitution under a constant hash must fail verification"
433 );
434 }
435
436 #[test]
440 fn verify_fails_closed_without_public_key_bytes() {
441 let mut m = empty_manifest();
442 m.signature = Some(ManifestSignature {
443 algorithm: "ed25519".to_owned(),
444 key_id: "ops@example.com".to_owned(),
445 value: "AAAA".to_owned(),
446 });
447 let mut tr = TrustRoot::new();
448 tr.allow("ops@example.com"); match verify_signed_manifest(&m, &tr) {
450 Err(PluginError::SignatureInvalid(msg)) => {
451 assert!(msg.contains("no public key bytes"), "msg: {msg}");
452 }
453 other => panic!("expected fail-closed SignatureInvalid, got {other:?}"),
454 }
455 }
456
457 #[test]
458 fn signature_with_unknown_algorithm_is_rejected() {
459 let mut m = empty_manifest();
460 m.signature = Some(ManifestSignature {
461 algorithm: "rsa".to_owned(),
462 key_id: "any".to_owned(),
463 value: String::new(),
464 });
465 let mut tr = TrustRoot::new();
466 tr.allow("any");
467 assert!(verify_signed_manifest(&m, &tr).is_err());
468 }
469
470 #[test]
471 fn unsigned_manifest_passes_signature_verifier() {
472 let m = empty_manifest();
473 let tr = TrustRoot::new();
474 assert!(verify_signed_manifest(&m, &tr).is_ok());
475 }
476
477 #[test]
478 fn policy_disabled_skips_verification() {
479 let mut m = empty_manifest();
482 m.signature = Some(ManifestSignature {
483 algorithm: "rsa".to_owned(),
484 key_id: "unknown".to_owned(),
485 value: String::new(),
486 });
487 let tr = TrustRoot::new();
488 assert!(verify_manifest_with_policy(&m, &tr, SignaturePolicy::Disabled).is_ok());
489 }
490
491 #[test]
492 fn policy_require_signed_rejects_unsigned_manifest() {
493 let m = empty_manifest();
494 let tr = TrustRoot::new();
495 let err = verify_manifest_with_policy(&m, &tr, SignaturePolicy::RequireSigned)
496 .expect_err("RequireSigned must reject unsigned manifest");
497 match err {
498 PluginError::SignatureInvalid(msg) => {
499 assert!(msg.contains("no manifest signature"), "msg: {msg}");
500 }
501 other => panic!("expected SignatureInvalid, got {other:?}"),
502 }
503 }
504
505 #[test]
506 fn policy_warn_if_unsigned_passes_unsigned_manifest() {
507 let m = empty_manifest();
508 let tr = TrustRoot::new();
509 assert!(verify_manifest_with_policy(&m, &tr, SignaturePolicy::WarnIfUnsigned).is_ok());
510 }
511
512 #[test]
513 fn constant_time_eq_basic() {
514 assert!(constant_time_eq("abc", "abc"));
515 assert!(!constant_time_eq("abc", "abd"));
516 assert!(!constant_time_eq("abc", "ab"));
517 }
518
519 #[test]
529 fn ed25519_sign_and_verify_round_trip_manually() {
530 use base64::Engine;
531 use ed25519_dalek::{Signer, SigningKey};
532
533 let seed: [u8; 32] = [
537 0x9d, 0x61, 0xb1, 0x9d, 0xef, 0xfd, 0x5a, 0x60, 0xba, 0x84, 0x4a, 0xf4, 0x92, 0xec,
538 0x2c, 0xc4, 0x44, 0x49, 0xc5, 0x69, 0x7b, 0x32, 0x69, 0x19, 0x70, 0x3b, 0xac, 0x03,
539 0x1c, 0xae, 0x7f, 0x60,
540 ];
541 let signing_key = SigningKey::from_bytes(&seed);
542 let verifying_key = signing_key.verifying_key();
543 let public_key_bytes: [u8; 32] = verifying_key.to_bytes();
544
545 let mut m = empty_manifest();
547 m.hash = Some(blake3::hash(b"plugin payload").to_hex().to_string());
548
549 let payload = canonical_payload(&m).expect("canonicalize");
551 let sig = signing_key.sign(&payload);
552 let sig_b64 = base64::engine::general_purpose::STANDARD.encode(sig.to_bytes());
553
554 let key = ed25519_dalek::VerifyingKey::from_bytes(&public_key_bytes).unwrap();
559 let decoded = base64::engine::general_purpose::STANDARD
560 .decode(sig_b64.as_bytes())
561 .unwrap();
562 let parsed_sig = ed25519_dalek::Signature::from_slice(&decoded).unwrap();
563 use ed25519_dalek::Verifier;
564 assert!(key.verify(&payload, &parsed_sig).is_ok());
565
566 let mut tampered = payload.clone();
568 tampered[0] ^= 0xff;
569 assert!(key.verify(&tampered, &parsed_sig).is_err());
570
571 let mut tr = TrustRoot::new();
573 tr.allow_with_key("ops@example.com", public_key_bytes);
574 assert_eq!(tr.public_key("ops@example.com"), Some(&public_key_bytes));
575 }
576}