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    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
47/// Verify a manifest's Ed25519 signature against the trust root.
48///
49/// Validates the signature algorithm, confirms the `key_id` is in the trust
50/// root, then cryptographically verifies the signature over the manifest's
51/// `canonical_payload` using the trust-root public key. An unsigned manifest
52/// passes — whether that is acceptable is the caller's policy decision (see
53/// [`verify_manifest_with_policy`]).
54///
55/// The verifier is fail-closed: a `key_id` present in the trust root but
56/// without bound public-key bytes (the [`TrustRoot::allow`] shape-only path) is
57/// rejected rather than waved through.
58///
59/// # Errors
60///
61/// Returns [`PluginError::SignatureInvalid`] when the signature's `algorithm`
62/// is not `"ed25519"`, the `key_id` is not in the trust root, the trust-root
63/// entry has no public-key bytes, the manifest cannot be canonicalized, or the
64/// cryptographic check fails.
65pub fn verify_signed_manifest(
66    manifest: &PluginManifest,
67    trust_root: &TrustRoot,
68) -> Result<(), PluginError> {
69    let Some(sig) = manifest.signature.as_ref() else {
70        // No signature; whether this is allowed depends on the
71        // host's `require_signed_plugins` configuration. The verifier
72        // doesn't enforce that policy — the caller does.
73        return Ok(());
74    };
75    // `algorithm` is matched explicitly so the verifier is algorithm-agile:
76    // a future scheme adds an arm here without weakening the ed25519 path.
77    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
99/// Domain-separation tag + format version prefixed to every signing payload.
100///
101/// Binding the signature to a tagged, versioned payload prevents cross-protocol
102/// signature reuse and gives a clean upgrade path: a future `:v2` encoding can
103/// never be confused with a `:v1` one. Changing this value invalidates every
104/// existing signature, so only bump the version suffix when the canonical
105/// encoding below changes.
106const MANIFEST_SIG_DOMAIN_V1: &[u8] = b"uni-plugin-manifest-sig:v1\0";
107
108/// Build the canonical bytes covered by a manifest's Ed25519 signature.
109///
110/// The payload is [`MANIFEST_SIG_DOMAIN_V1`] followed by the manifest
111/// serialized as JSON with its own `signature` field cleared. Clearing the
112/// signature avoids the self-reference paradox (a signature cannot cover
113/// itself), and every other field — `id`, `version`, `abi`, `capabilities`,
114/// `side_effects`, `determinism`, `scope`, `hash`, … — is included, so a
115/// manifest-substitution attack that preserves only the `hash` pin no longer
116/// verifies.
117///
118/// Serialization routes through [`serde_json::Value`] so object keys are
119/// emitted in sorted order independent of struct field declaration order: the
120/// encoding (and therefore existing signatures) stays stable across field
121/// reorderings. Determinism holds because every map in the manifest is a
122/// `BTreeMap` / `BTreeSet` and every list preserves authored order.
123///
124/// # Errors
125///
126/// Returns [`PluginError::SignatureInvalid`] if the manifest cannot be
127/// serialized to JSON (not expected for a well-formed manifest).
128fn 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/// Trust root for plugin signature verification.
164///
165/// Configured per-Uni-instance from secure storage (KMS, config file).
166#[derive(Debug, Default)]
167pub struct TrustRoot {
168    /// `key_id → Option<public-key-bytes>` — the bytes are populated
169    /// when the `ed25519` feature is enabled and the trust root is
170    /// configured with real public-key material.
171    allowed_keys: std::collections::BTreeMap<String, Option<[u8; 32]>>,
172}
173
174impl TrustRoot {
175    /// Construct an empty trust root (rejects every signed manifest).
176    #[must_use]
177    pub fn new() -> Self {
178        Self::default()
179    }
180
181    /// Add an allowed key id without binding public-key bytes.
182    ///
183    /// Useful for tests / shape-only verification. Real builds (with the
184    /// `ed25519` feature) should use [`TrustRoot::allow_with_key`].
185    pub fn allow(&mut self, key_id: impl Into<String>) {
186        self.allowed_keys.insert(key_id.into(), None);
187    }
188
189    /// Add an allowed key with its 32-byte Ed25519 public key.
190    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    /// Check whether `key_id` is in the trust root.
195    #[must_use]
196    pub fn contains(&self, key_id: &str) -> bool {
197        self.allowed_keys.contains_key(key_id)
198    }
199
200    /// Return the 32-byte public key for `key_id`, if known.
201    #[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/// Host policy for plugin signature enforcement.
208///
209/// Wraps [`verify_signed_manifest`] with a "should an unsigned manifest
210/// be accepted?" decision so the host can dial enforcement up over time
211/// without changing call sites.
212#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
213pub enum SignaturePolicy {
214    /// Skip signature checks entirely. Default for v1 back-compat —
215    /// unsigned and signed manifests both pass without inspection.
216    #[default]
217    Disabled,
218    /// Verify when a signature is present; log a warning when absent.
219    WarnIfUnsigned,
220    /// Reject any manifest without a valid signature.
221    RequireSigned,
222}
223
224/// Apply [`SignaturePolicy`] on top of [`verify_signed_manifest`].
225///
226/// `Disabled` short-circuits without inspecting the manifest.
227/// `WarnIfUnsigned` runs the verifier and emits a `tracing::warn` when
228/// the manifest has no signature. `RequireSigned` runs the verifier and
229/// converts an absent signature into [`PluginError::SignatureInvalid`].
230///
231/// # Errors
232///
233/// Forwards every error from [`verify_signed_manifest`]. Additionally
234/// returns [`PluginError::SignatureInvalid`] when the policy requires a
235/// signature and the manifest has none.
236pub 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
264/// Constant-time string equality.
265///
266/// Hash-pins are *not* secrets, but constant-time comparison is cheap
267/// and defends against the future case where the same primitive is
268/// reused for HMAC tags.
269fn 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    /// **M11 cutover end-to-end test**: real Ed25519 sign + verify
347    /// through `verify_signed_manifest`. Exercises the full flow: build
348    /// a manifest with hash-pin, sign the canonical payload with a
349    /// trust-root key, base64-encode the signature into
350    /// `manifest.signature.value`, and call `verify_signed_manifest`.
351    /// With `ed25519` default-on, this performs real crypto.
352    #[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        // Real cryptographic verification — passes only if the signature
382        // is valid over the canonical payload.
383        verify_signed_manifest(&m, &tr).expect("real Ed25519 verify must succeed");
384
385        // Tampering with the manifest's hash invalidates the signature.
386        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    /// Regression for the manifest-substitution attack: the signature must
394    /// cover security-relevant fields, not just the hash pin. Mutating
395    /// `capabilities` while leaving `hash` unchanged must fail verification.
396    /// This is exactly the case the old hash-only payload accepted.
397    #[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        // Sign a read-only manifest with a fixed hash pin.
411        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        // Attacker escalates capabilities + side-effects but keeps the hash
427        // pin (and signature) identical. Must now be rejected.
428        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    /// Fail-closed: a `key_id` present in the trust root but with no bound
437    /// public-key bytes (the shape-only `allow()` path) must be rejected, not
438    /// waved through.
439    #[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"); // membership only, no key bytes
449        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        // A manifest with a bogus signature still passes when the host
480        // has signature enforcement disabled.
481        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    /// End-to-end ed25519 signing + verification round-trip.
520    ///
521    /// Uses `ed25519-dalek` directly (a dev-dep) to sign the canonical
522    /// payload, populates `TrustRoot` with the public key bytes,
523    /// and verifies — proving the M11 cryptographic path works.
524    ///
525    /// This test exercises the raw `ed25519-dalek` round-trip arithmetic
526    /// directly; the `verify_signed_manifest` integration is covered by
527    /// `verify_signed_manifest_real_ed25519_round_trip` above.
528    #[test]
529    fn ed25519_sign_and_verify_round_trip_manually() {
530        use base64::Engine;
531        use ed25519_dalek::{Signer, SigningKey};
532
533        // Deterministic 32-byte seed → reproducible keypair. Avoids the
534        // rand-version-skew dance between workspace rand (0.9) and
535        // ed25519-dalek's rand_core (0.6) trait bound.
536        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        // Build a manifest with a hash-pin (the canonical-payload input).
546        let mut m = empty_manifest();
547        m.hash = Some(blake3::hash(b"plugin payload").to_hex().to_string());
548
549        // Sign the canonical payload.
550        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        // Verify via the M11 path using ed25519-dalek directly.
555        // (When the `ed25519` cargo feature is enabled, `verify_signed_manifest`
556        // performs this verification automatically; this test reproduces it
557        // unconditionally to lock the protocol shape.)
558        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        // Tampered payload fails verification.
567        let mut tampered = payload.clone();
568        tampered[0] ^= 0xff;
569        assert!(key.verify(&tampered, &parsed_sig).is_err());
570
571        // TrustRoot stores the public key correctly.
572        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}