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 verify_payload_hash(expected_hex, payload)
37}
38
39pub fn verify_payload_hash(expected_hex: &str, payload: &[u8]) -> Result<(), PluginError> {
54 let actual_hex = blake3::hash(payload).to_hex().to_string();
55 if !constant_time_eq(expected_hex, &actual_hex) {
56 return Err(PluginError::HashMismatch {
57 expected: expected_hex.to_string(),
58 actual: actual_hex,
59 });
60 }
61 Ok(())
62}
63
64pub fn verify_payload_in_allowlist<S>(
78 allowlist: &std::collections::BTreeSet<S>,
79 payload: &[u8],
80) -> Result<(), PluginError>
81where
82 S: AsRef<str> + Ord,
83{
84 if allowlist.is_empty() {
85 return Ok(());
86 }
87 let actual = blake3::hash(payload).to_hex().to_string();
88 if allowlist
89 .iter()
90 .any(|p| constant_time_eq(p.as_ref(), &actual))
91 {
92 return Ok(());
93 }
94 Err(PluginError::HashMismatch {
95 expected: allowlist
96 .iter()
97 .next()
98 .map(|s| s.as_ref().to_string())
99 .unwrap_or_default(),
100 actual,
101 })
102}
103
104pub fn verify_signed_manifest(
123 manifest: &PluginManifest,
124 trust_root: &TrustRoot,
125) -> Result<(), PluginError> {
126 let Some(sig) = manifest.signature.as_ref() else {
127 return Ok(());
131 };
132 if sig.algorithm != "ed25519" {
135 return Err(PluginError::SignatureInvalid(format!(
136 "unsupported algorithm `{}`",
137 sig.algorithm
138 )));
139 }
140 if !trust_root.contains(&sig.key_id) {
141 return Err(PluginError::SignatureInvalid(format!(
142 "key `{}` not in trust root",
143 sig.key_id
144 )));
145 }
146 let public_key_bytes = trust_root.public_key(&sig.key_id).ok_or_else(|| {
147 PluginError::SignatureInvalid(format!(
148 "trust root for key `{}` has no public key bytes",
149 sig.key_id
150 ))
151 })?;
152 let signing_payload = canonical_payload(manifest)?;
153 verify_ed25519(public_key_bytes, &signing_payload, &sig.value)
154}
155
156const MANIFEST_SIG_DOMAIN_V1: &[u8] = b"uni-plugin-manifest-sig:v1\0";
164
165fn canonical_payload(manifest: &PluginManifest) -> Result<Vec<u8>, PluginError> {
186 let mut unsigned = manifest.clone();
187 unsigned.signature = None;
188 let value = serde_json::to_value(&unsigned).map_err(|e| {
189 PluginError::SignatureInvalid(format!("manifest canonicalization failed: {e}"))
190 })?;
191 let json = serde_json::to_vec(&value).map_err(|e| {
192 PluginError::SignatureInvalid(format!("manifest canonicalization failed: {e}"))
193 })?;
194 let mut bytes = Vec::with_capacity(MANIFEST_SIG_DOMAIN_V1.len() + json.len());
195 bytes.extend_from_slice(MANIFEST_SIG_DOMAIN_V1);
196 bytes.extend_from_slice(&json);
197 Ok(bytes)
198}
199
200fn verify_ed25519(
201 public_key_bytes: &[u8; 32],
202 payload: &[u8],
203 signature_b64: &str,
204) -> Result<(), PluginError> {
205 use base64::Engine;
206 use ed25519_dalek::{Signature, Verifier, VerifyingKey};
207
208 let key = VerifyingKey::from_bytes(public_key_bytes)
209 .map_err(|e| PluginError::SignatureInvalid(format!("malformed ed25519 public key: {e}")))?;
210 let sig_bytes = base64::engine::general_purpose::STANDARD
211 .decode(signature_b64.as_bytes())
212 .map_err(|e| PluginError::SignatureInvalid(format!("signature base64: {e}")))?;
213 let sig = Signature::from_slice(&sig_bytes)
214 .map_err(|e| PluginError::SignatureInvalid(format!("signature parse: {e}")))?;
215 key.verify(payload, &sig)
216 .map_err(|e| PluginError::SignatureInvalid(format!("ed25519 verify failed: {e}")))?;
217 Ok(())
218}
219
220#[derive(Debug, Default)]
224pub struct TrustRoot {
225 allowed_keys: std::collections::BTreeMap<String, Option<[u8; 32]>>,
229}
230
231impl TrustRoot {
232 #[must_use]
234 pub fn new() -> Self {
235 Self::default()
236 }
237
238 pub fn allow(&mut self, key_id: impl Into<String>) {
243 self.allowed_keys.insert(key_id.into(), None);
244 }
245
246 pub fn allow_with_key(&mut self, key_id: impl Into<String>, public_key: [u8; 32]) {
248 self.allowed_keys.insert(key_id.into(), Some(public_key));
249 }
250
251 #[must_use]
253 pub fn contains(&self, key_id: &str) -> bool {
254 self.allowed_keys.contains_key(key_id)
255 }
256
257 #[must_use]
259 pub fn public_key(&self, key_id: &str) -> Option<&[u8; 32]> {
260 self.allowed_keys.get(key_id).and_then(|k| k.as_ref())
261 }
262}
263
264#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
270pub enum SignaturePolicy {
271 #[default]
274 Disabled,
275 WarnIfUnsigned,
277 RequireSigned,
279}
280
281pub fn verify_manifest_with_policy(
294 manifest: &PluginManifest,
295 trust_root: &TrustRoot,
296 policy: SignaturePolicy,
297) -> Result<(), PluginError> {
298 match policy {
299 SignaturePolicy::Disabled => Ok(()),
300 SignaturePolicy::WarnIfUnsigned => {
301 if manifest.signature.is_none() {
302 tracing::warn!(
303 plugin_id = %manifest.id.as_str(),
304 "plugin manifest has no signature; accepted under WarnIfUnsigned policy",
305 );
306 }
307 verify_signed_manifest(manifest, trust_root)
308 }
309 SignaturePolicy::RequireSigned => {
310 if manifest.signature.is_none() {
311 return Err(PluginError::SignatureInvalid(format!(
312 "plugin `{}` has no manifest signature; RequireSigned policy rejects it",
313 manifest.id.as_str()
314 )));
315 }
316 verify_signed_manifest(manifest, trust_root)
317 }
318 }
319}
320
321fn constant_time_eq(a: &str, b: &str) -> bool {
327 if a.len() != b.len() {
328 return false;
329 }
330 let mut diff: u8 = 0;
331 for (ai, bi) in a.bytes().zip(b.bytes()) {
332 diff |= ai ^ bi;
333 }
334 diff == 0
335}
336
337#[cfg(test)]
338mod tests {
339 use super::*;
340 use crate::manifest::AbiRange;
341 use crate::plugin::PluginId;
342 use crate::{Determinism, Scope, SideEffects};
343 use semver::Version;
344
345 fn empty_manifest() -> PluginManifest {
346 PluginManifest {
347 id: PluginId::new("test"),
348 version: Version::new(0, 1, 0),
349 abi: AbiRange::parse("^1").unwrap(),
350 depends_on: vec![],
351 capabilities: crate::CapabilitySet::new(),
352 determinism: Determinism::Pure,
353 side_effects: SideEffects::ReadOnly,
354 scope: Scope::Instance,
355 hash: None,
356 signature: None,
357 provides: crate::ProvidedSurfaces::default(),
358 docs: String::new(),
359 metadata: std::collections::BTreeMap::new(),
360 }
361 }
362
363 #[test]
364 fn hash_pin_passes_when_unpinned() {
365 let m = empty_manifest();
366 assert!(verify_hash_pin(&m, b"anything").is_ok());
367 }
368
369 #[test]
370 fn hash_pin_passes_with_correct_hash() {
371 let mut m = empty_manifest();
372 let payload = b"hello world";
373 m.hash = Some(blake3::hash(payload).to_hex().to_string());
374 assert!(verify_hash_pin(&m, payload).is_ok());
375 }
376
377 #[test]
378 fn hash_pin_fails_with_wrong_hash() {
379 let mut m = empty_manifest();
380 m.hash = Some(blake3::hash(b"a").to_hex().to_string());
381 match verify_hash_pin(&m, b"b") {
382 Err(PluginError::HashMismatch { expected, actual }) => {
383 assert!(!expected.is_empty());
384 assert!(!actual.is_empty());
385 assert_ne!(expected, actual);
386 }
387 other => panic!("expected HashMismatch, got {other:?}"),
388 }
389 }
390
391 #[test]
392 fn signature_verification_rejects_unknown_key_id() {
393 let mut m = empty_manifest();
394 m.signature = Some(ManifestSignature {
395 algorithm: "ed25519".to_owned(),
396 key_id: "ops@example.com".to_owned(),
397 value: "base64...".to_owned(),
398 });
399 let tr = TrustRoot::new();
400 assert!(verify_signed_manifest(&m, &tr).is_err());
401 }
402
403 #[test]
410 fn verify_signed_manifest_real_ed25519_round_trip() {
411 use base64::Engine;
412 use ed25519_dalek::{Signer, SigningKey};
413
414 let seed: [u8; 32] = [
415 0x9d, 0x61, 0xb1, 0x9d, 0xef, 0xfd, 0x5a, 0x60, 0xba, 0x84, 0x4a, 0xf4, 0x92, 0xec,
416 0x2c, 0xc4, 0x44, 0x49, 0xc5, 0x69, 0x7b, 0x32, 0x69, 0x19, 0x70, 0x3b, 0xac, 0x03,
417 0x1c, 0xae, 0x7f, 0x60,
418 ];
419 let signing_key = SigningKey::from_bytes(&seed);
420 let public_key_bytes: [u8; 32] = signing_key.verifying_key().to_bytes();
421
422 let mut m = empty_manifest();
423 m.hash = Some(blake3::hash(b"plugin payload").to_hex().to_string());
424
425 let payload = canonical_payload(&m).expect("canonicalize");
426 let sig = signing_key.sign(&payload);
427 let sig_b64 = base64::engine::general_purpose::STANDARD.encode(sig.to_bytes());
428
429 m.signature = Some(ManifestSignature {
430 algorithm: "ed25519".to_owned(),
431 key_id: "ops@example.com".to_owned(),
432 value: sig_b64,
433 });
434
435 let mut tr = TrustRoot::new();
436 tr.allow_with_key("ops@example.com", public_key_bytes);
437
438 verify_signed_manifest(&m, &tr).expect("real Ed25519 verify must succeed");
441
442 m.hash = Some(blake3::hash(b"different payload").to_hex().to_string());
444 assert!(
445 verify_signed_manifest(&m, &tr).is_err(),
446 "tampered manifest must fail verification"
447 );
448 }
449
450 #[test]
455 fn verify_rejects_capability_substitution() {
456 use base64::Engine;
457 use ed25519_dalek::{Signer, SigningKey};
458
459 let seed: [u8; 32] = [
460 0x9d, 0x61, 0xb1, 0x9d, 0xef, 0xfd, 0x5a, 0x60, 0xba, 0x84, 0x4a, 0xf4, 0x92, 0xec,
461 0x2c, 0xc4, 0x44, 0x49, 0xc5, 0x69, 0x7b, 0x32, 0x69, 0x19, 0x70, 0x3b, 0xac, 0x03,
462 0x1c, 0xae, 0x7f, 0x60,
463 ];
464 let signing_key = SigningKey::from_bytes(&seed);
465 let public_key_bytes: [u8; 32] = signing_key.verifying_key().to_bytes();
466
467 let mut m = empty_manifest();
469 m.hash = Some(blake3::hash(b"plugin payload").to_hex().to_string());
470 let payload = canonical_payload(&m).expect("canonicalize");
471 let sig_b64 =
472 base64::engine::general_purpose::STANDARD.encode(signing_key.sign(&payload).to_bytes());
473 m.signature = Some(ManifestSignature {
474 algorithm: "ed25519".to_owned(),
475 key_id: "ops@example.com".to_owned(),
476 value: sig_b64,
477 });
478
479 let mut tr = TrustRoot::new();
480 tr.allow_with_key("ops@example.com", public_key_bytes);
481 verify_signed_manifest(&m, &tr).expect("baseline signed manifest must verify");
482
483 m.capabilities.insert(crate::Capability::ProcedureWrites);
486 m.side_effects = SideEffects::Writes;
487 assert!(
488 verify_signed_manifest(&m, &tr).is_err(),
489 "capability substitution under a constant hash must fail verification"
490 );
491 }
492
493 #[test]
497 fn verify_fails_closed_without_public_key_bytes() {
498 let mut m = empty_manifest();
499 m.signature = Some(ManifestSignature {
500 algorithm: "ed25519".to_owned(),
501 key_id: "ops@example.com".to_owned(),
502 value: "AAAA".to_owned(),
503 });
504 let mut tr = TrustRoot::new();
505 tr.allow("ops@example.com"); match verify_signed_manifest(&m, &tr) {
507 Err(PluginError::SignatureInvalid(msg)) => {
508 assert!(msg.contains("no public key bytes"), "msg: {msg}");
509 }
510 other => panic!("expected fail-closed SignatureInvalid, got {other:?}"),
511 }
512 }
513
514 #[test]
515 fn signature_with_unknown_algorithm_is_rejected() {
516 let mut m = empty_manifest();
517 m.signature = Some(ManifestSignature {
518 algorithm: "rsa".to_owned(),
519 key_id: "any".to_owned(),
520 value: String::new(),
521 });
522 let mut tr = TrustRoot::new();
523 tr.allow("any");
524 assert!(verify_signed_manifest(&m, &tr).is_err());
525 }
526
527 #[test]
528 fn unsigned_manifest_passes_signature_verifier() {
529 let m = empty_manifest();
530 let tr = TrustRoot::new();
531 assert!(verify_signed_manifest(&m, &tr).is_ok());
532 }
533
534 #[test]
535 fn policy_disabled_skips_verification() {
536 let mut m = empty_manifest();
539 m.signature = Some(ManifestSignature {
540 algorithm: "rsa".to_owned(),
541 key_id: "unknown".to_owned(),
542 value: String::new(),
543 });
544 let tr = TrustRoot::new();
545 assert!(verify_manifest_with_policy(&m, &tr, SignaturePolicy::Disabled).is_ok());
546 }
547
548 #[test]
549 fn policy_require_signed_rejects_unsigned_manifest() {
550 let m = empty_manifest();
551 let tr = TrustRoot::new();
552 let err = verify_manifest_with_policy(&m, &tr, SignaturePolicy::RequireSigned)
553 .expect_err("RequireSigned must reject unsigned manifest");
554 match err {
555 PluginError::SignatureInvalid(msg) => {
556 assert!(msg.contains("no manifest signature"), "msg: {msg}");
557 }
558 other => panic!("expected SignatureInvalid, got {other:?}"),
559 }
560 }
561
562 #[test]
563 fn policy_warn_if_unsigned_passes_unsigned_manifest() {
564 let m = empty_manifest();
565 let tr = TrustRoot::new();
566 assert!(verify_manifest_with_policy(&m, &tr, SignaturePolicy::WarnIfUnsigned).is_ok());
567 }
568
569 #[test]
570 fn constant_time_eq_basic() {
571 assert!(constant_time_eq("abc", "abc"));
572 assert!(!constant_time_eq("abc", "abd"));
573 assert!(!constant_time_eq("abc", "ab"));
574 }
575
576 #[test]
586 fn ed25519_sign_and_verify_round_trip_manually() {
587 use base64::Engine;
588 use ed25519_dalek::{Signer, SigningKey};
589
590 let seed: [u8; 32] = [
594 0x9d, 0x61, 0xb1, 0x9d, 0xef, 0xfd, 0x5a, 0x60, 0xba, 0x84, 0x4a, 0xf4, 0x92, 0xec,
595 0x2c, 0xc4, 0x44, 0x49, 0xc5, 0x69, 0x7b, 0x32, 0x69, 0x19, 0x70, 0x3b, 0xac, 0x03,
596 0x1c, 0xae, 0x7f, 0x60,
597 ];
598 let signing_key = SigningKey::from_bytes(&seed);
599 let verifying_key = signing_key.verifying_key();
600 let public_key_bytes: [u8; 32] = verifying_key.to_bytes();
601
602 let mut m = empty_manifest();
604 m.hash = Some(blake3::hash(b"plugin payload").to_hex().to_string());
605
606 let payload = canonical_payload(&m).expect("canonicalize");
608 let sig = signing_key.sign(&payload);
609 let sig_b64 = base64::engine::general_purpose::STANDARD.encode(sig.to_bytes());
610
611 let key = ed25519_dalek::VerifyingKey::from_bytes(&public_key_bytes).unwrap();
616 let decoded = base64::engine::general_purpose::STANDARD
617 .decode(sig_b64.as_bytes())
618 .unwrap();
619 let parsed_sig = ed25519_dalek::Signature::from_slice(&decoded).unwrap();
620 use ed25519_dalek::Verifier;
621 assert!(key.verify(&payload, &parsed_sig).is_ok());
622
623 let mut tampered = payload.clone();
625 tampered[0] ^= 0xff;
626 assert!(key.verify(&tampered, &parsed_sig).is_err());
627
628 let mut tr = TrustRoot::new();
630 tr.allow_with_key("ops@example.com", public_key_bytes);
631 assert_eq!(tr.public_key("ops@example.com"), Some(&public_key_bytes));
632 }
633}
634
635#[cfg(test)]
636mod allowlist_tests {
637 use super::*;
638 use std::collections::BTreeSet;
639
640 fn digest(bytes: &[u8]) -> String {
641 blake3::hash(bytes).to_hex().to_string()
642 }
643
644 #[test]
645 fn empty_allowlist_disables_pinning() {
646 let empty: BTreeSet<String> = BTreeSet::new();
647 assert!(verify_payload_in_allowlist(&empty, b"anything").is_ok());
648 }
649
650 #[test]
651 fn listed_payload_passes() {
652 let mut allow = BTreeSet::new();
653 allow.insert(digest(b"plugin-bytes"));
654 assert!(verify_payload_in_allowlist(&allow, b"plugin-bytes").is_ok());
655 }
656
657 #[test]
658 fn unlisted_payload_is_rejected() {
659 let mut allow = BTreeSet::new();
660 allow.insert(digest(b"good"));
661 match verify_payload_in_allowlist(&allow, b"tampered") {
662 Err(PluginError::HashMismatch { expected, actual }) => {
663 assert_eq!(expected, digest(b"good"));
664 assert_eq!(actual, digest(b"tampered"));
665 }
666 other => panic!("expected HashMismatch, got {other:?}"),
667 }
668 }
669
670 #[test]
671 fn allowlist_admits_several_pins() {
672 let mut allow = BTreeSet::new();
673 allow.insert(digest(b"v1"));
674 allow.insert(digest(b"v2"));
675 assert!(verify_payload_in_allowlist(&allow, b"v1").is_ok());
676 assert!(verify_payload_in_allowlist(&allow, b"v2").is_ok());
677 assert!(verify_payload_in_allowlist(&allow, b"v3").is_err());
678 }
679}