Skip to main content

uni_plugin/
verify.rs

1//! Manifest signing and hash-pinning verification.
2//!
3//! Production deployments can require:
4//!
5//! - **Ed25519 signed manifests** — the manifest's `signature` field is
6//!   verified against a trust root (configured per Uni instance).
7//! - **Blake3 hash pinning** — the manifest's `hash` field must match
8//!   a hash recorded at first install; reloads must reproduce.
9//!
10//! Ed25519 signature verification is always compiled — it is a security
11//! primitive, so it is deliberately not a build-time opt-out. The signature
12//! covers the whole manifest (see `canonical_payload`), not just the hash
13//! pin, which closes a manifest-substitution attack: rewriting `capabilities`
14//! or `side_effects` while preserving the hash invalidates the signature.
15
16use crate::errors::PluginError;
17use crate::manifest::PluginManifest;
18
19#[cfg(test)]
20use crate::manifest::ManifestSignature;
21
22/// Verify a plugin's hash-pin against the payload bytes.
23///
24/// The manifest's `hash` field, if present, must equal `blake3(payload)`
25/// in hex. Returns `Ok(())` if there is no pin (the manifest opted out)
26/// or if the pin matches.
27///
28/// # Errors
29///
30/// Returns [`PluginError::HashMismatch`] when the pin is set and the
31/// computed hash differs.
32pub 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
39/// Verify raw payload bytes against an externally-supplied Blake3 hex digest.
40///
41/// This is the primitive behind [`verify_hash_pin`], exposed separately because
42/// the security-meaningful case is a pin that arrives from *outside* the
43/// artifact — a host allowlist or an install record. A digest read out of the
44/// artifact's own embedded manifest is self-certifying: an attacker who can
45/// rewrite the payload can rewrite the digest beside it. Only an Ed25519
46/// signature over the manifest (see [`verify_manifest_with_policy`]) makes an
47/// embedded pin trustworthy.
48///
49/// # Errors
50///
51/// Returns [`PluginError::HashMismatch`] when `blake3(payload)` differs from
52/// `expected_hex`.
53pub 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
64/// Verify payload bytes against a host-configured allowlist of Blake3 digests.
65///
66/// An empty allowlist means "pinning disabled" and accepts everything. A
67/// non-empty one accepts only payloads whose digest it contains, which lets an
68/// operator restrict an instance to a known-good set of plugin binaries
69/// independent of anything the artifacts assert about themselves.
70///
71/// # Errors
72///
73/// Returns [`PluginError::HashMismatch`] when the allowlist is non-empty and
74/// the payload's digest is absent from it. `expected` carries the
75/// lexicographically first configured pin so the operator sees a concrete
76/// value to diff against.
77pub 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
104/// Verify a manifest's Ed25519 signature against the trust root.
105///
106/// Validates the signature algorithm, confirms the `key_id` is in the trust
107/// root, then cryptographically verifies the signature over the manifest's
108/// `canonical_payload` using the trust-root public key. An unsigned manifest
109/// passes — whether that is acceptable is the caller's policy decision (see
110/// [`verify_manifest_with_policy`]).
111///
112/// The verifier is fail-closed: a `key_id` present in the trust root but
113/// without bound public-key bytes (the [`TrustRoot::allow`] shape-only path) is
114/// rejected rather than waved through.
115///
116/// # Errors
117///
118/// Returns [`PluginError::SignatureInvalid`] when the signature's `algorithm`
119/// is not `"ed25519"`, the `key_id` is not in the trust root, the trust-root
120/// entry has no public-key bytes, the manifest cannot be canonicalized, or the
121/// cryptographic check fails.
122pub fn verify_signed_manifest(
123    manifest: &PluginManifest,
124    trust_root: &TrustRoot,
125) -> Result<(), PluginError> {
126    let Some(sig) = manifest.signature.as_ref() else {
127        // No signature; whether this is allowed depends on the
128        // host's `require_signed_plugins` configuration. The verifier
129        // doesn't enforce that policy — the caller does.
130        return Ok(());
131    };
132    // `algorithm` is matched explicitly so the verifier is algorithm-agile:
133    // a future scheme adds an arm here without weakening the ed25519 path.
134    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
156/// Domain-separation tag + format version prefixed to every signing payload.
157///
158/// Binding the signature to a tagged, versioned payload prevents cross-protocol
159/// signature reuse and gives a clean upgrade path: a future `:v2` encoding can
160/// never be confused with a `:v1` one. Changing this value invalidates every
161/// existing signature, so only bump the version suffix when the canonical
162/// encoding below changes.
163const MANIFEST_SIG_DOMAIN_V1: &[u8] = b"uni-plugin-manifest-sig:v1\0";
164
165/// Build the canonical bytes covered by a manifest's Ed25519 signature.
166///
167/// The payload is [`MANIFEST_SIG_DOMAIN_V1`] followed by the manifest
168/// serialized as JSON with its own `signature` field cleared. Clearing the
169/// signature avoids the self-reference paradox (a signature cannot cover
170/// itself), and every other field — `id`, `version`, `abi`, `capabilities`,
171/// `side_effects`, `determinism`, `scope`, `hash`, … — is included, so a
172/// manifest-substitution attack that preserves only the `hash` pin no longer
173/// verifies.
174///
175/// Serialization routes through [`serde_json::Value`] so object keys are
176/// emitted in sorted order independent of struct field declaration order: the
177/// encoding (and therefore existing signatures) stays stable across field
178/// reorderings. Determinism holds because every map in the manifest is a
179/// `BTreeMap` / `BTreeSet` and every list preserves authored order.
180///
181/// # Errors
182///
183/// Returns [`PluginError::SignatureInvalid`] if the manifest cannot be
184/// serialized to JSON (not expected for a well-formed manifest).
185fn 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/// Trust root for plugin signature verification.
221///
222/// Configured per-Uni-instance from secure storage (KMS, config file).
223#[derive(Debug, Default)]
224pub struct TrustRoot {
225    /// `key_id → Option<public-key-bytes>` — the bytes are populated
226    /// when the `ed25519` feature is enabled and the trust root is
227    /// configured with real public-key material.
228    allowed_keys: std::collections::BTreeMap<String, Option<[u8; 32]>>,
229}
230
231impl TrustRoot {
232    /// Construct an empty trust root (rejects every signed manifest).
233    #[must_use]
234    pub fn new() -> Self {
235        Self::default()
236    }
237
238    /// Add an allowed key id without binding public-key bytes.
239    ///
240    /// Useful for tests / shape-only verification. Real builds (with the
241    /// `ed25519` feature) should use [`TrustRoot::allow_with_key`].
242    pub fn allow(&mut self, key_id: impl Into<String>) {
243        self.allowed_keys.insert(key_id.into(), None);
244    }
245
246    /// Add an allowed key with its 32-byte Ed25519 public key.
247    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    /// Check whether `key_id` is in the trust root.
252    #[must_use]
253    pub fn contains(&self, key_id: &str) -> bool {
254        self.allowed_keys.contains_key(key_id)
255    }
256
257    /// Return the 32-byte public key for `key_id`, if known.
258    #[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/// Host policy for plugin signature enforcement.
265///
266/// Wraps [`verify_signed_manifest`] with a "should an unsigned manifest
267/// be accepted?" decision so the host can dial enforcement up over time
268/// without changing call sites.
269#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
270pub enum SignaturePolicy {
271    /// Skip signature checks entirely. Default for v1 back-compat —
272    /// unsigned and signed manifests both pass without inspection.
273    #[default]
274    Disabled,
275    /// Verify when a signature is present; log a warning when absent.
276    WarnIfUnsigned,
277    /// Reject any manifest without a valid signature.
278    RequireSigned,
279}
280
281/// Apply [`SignaturePolicy`] on top of [`verify_signed_manifest`].
282///
283/// `Disabled` short-circuits without inspecting the manifest.
284/// `WarnIfUnsigned` runs the verifier and emits a `tracing::warn` when
285/// the manifest has no signature. `RequireSigned` runs the verifier and
286/// converts an absent signature into [`PluginError::SignatureInvalid`].
287///
288/// # Errors
289///
290/// Forwards every error from [`verify_signed_manifest`]. Additionally
291/// returns [`PluginError::SignatureInvalid`] when the policy requires a
292/// signature and the manifest has none.
293pub 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
321/// Constant-time string equality.
322///
323/// Hash-pins are *not* secrets, but constant-time comparison is cheap
324/// and defends against the future case where the same primitive is
325/// reused for HMAC tags.
326fn 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    /// **M11 cutover end-to-end test**: real Ed25519 sign + verify
404    /// through `verify_signed_manifest`. Exercises the full flow: build
405    /// a manifest with hash-pin, sign the canonical payload with a
406    /// trust-root key, base64-encode the signature into
407    /// `manifest.signature.value`, and call `verify_signed_manifest`.
408    /// With `ed25519` default-on, this performs real crypto.
409    #[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        // Real cryptographic verification — passes only if the signature
439        // is valid over the canonical payload.
440        verify_signed_manifest(&m, &tr).expect("real Ed25519 verify must succeed");
441
442        // Tampering with the manifest's hash invalidates the signature.
443        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    /// Regression for the manifest-substitution attack: the signature must
451    /// cover security-relevant fields, not just the hash pin. Mutating
452    /// `capabilities` while leaving `hash` unchanged must fail verification.
453    /// This is exactly the case the old hash-only payload accepted.
454    #[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        // Sign a read-only manifest with a fixed hash pin.
468        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        // Attacker escalates capabilities + side-effects but keeps the hash
484        // pin (and signature) identical. Must now be rejected.
485        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    /// Fail-closed: a `key_id` present in the trust root but with no bound
494    /// public-key bytes (the shape-only `allow()` path) must be rejected, not
495    /// waved through.
496    #[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"); // membership only, no key bytes
506        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        // A manifest with a bogus signature still passes when the host
537        // has signature enforcement disabled.
538        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    /// End-to-end ed25519 signing + verification round-trip.
577    ///
578    /// Uses `ed25519-dalek` directly (a dev-dep) to sign the canonical
579    /// payload, populates `TrustRoot` with the public key bytes,
580    /// and verifies — proving the M11 cryptographic path works.
581    ///
582    /// This test exercises the raw `ed25519-dalek` round-trip arithmetic
583    /// directly; the `verify_signed_manifest` integration is covered by
584    /// `verify_signed_manifest_real_ed25519_round_trip` above.
585    #[test]
586    fn ed25519_sign_and_verify_round_trip_manually() {
587        use base64::Engine;
588        use ed25519_dalek::{Signer, SigningKey};
589
590        // Deterministic 32-byte seed → reproducible keypair. Avoids the
591        // rand-version-skew dance between workspace rand (0.9) and
592        // ed25519-dalek's rand_core (0.6) trait bound.
593        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        // Build a manifest with a hash-pin (the canonical-payload input).
603        let mut m = empty_manifest();
604        m.hash = Some(blake3::hash(b"plugin payload").to_hex().to_string());
605
606        // Sign the canonical payload.
607        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        // Verify via the M11 path using ed25519-dalek directly.
612        // (When the `ed25519` cargo feature is enabled, `verify_signed_manifest`
613        // performs this verification automatically; this test reproduces it
614        // unconditionally to lock the protocol shape.)
615        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        // Tampered payload fails verification.
624        let mut tampered = payload.clone();
625        tampered[0] ^= 0xff;
626        assert!(key.verify(&tampered, &parsed_sig).is_err());
627
628        // TrustRoot stores the public key correctly.
629        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}