Skip to main content

agent_mesh_protocol/
agent_key.rs

1//! [`AgentKey`] — a short-lived per-agent ed25519 sub-key, certified
2//! by a [`UserKey`].
3//!
4//! Agent keys are issued in memory (`AgentKey::issue`) and never
5//! persisted. Each one carries a [`CertChain`] proving the user
6//! signed off on this agent's identity and metadata. Peers verify the
7//! cert chain once on first contact and cache the agent's public key.
8
9use crate::caveats::Caveats;
10use crate::fingerprint::Fingerprint;
11use crate::user_key::{UserKey, UserPublic};
12use crate::{MeshError, Result};
13use ed25519_dalek::{Signature, Signer, SigningKey, VerifyingKey};
14use rand::rngs::OsRng;
15use serde::{Deserialize, Serialize};
16
17/// A short-lived per-agent keypair, signed by the user's root key.
18///
19/// `AgentKey` deliberately omits any save/load API: agent keys live
20/// in memory for the lifetime of the agent process and are
21/// regenerated on restart. The certificate ([`AgentKey::cert`])
22/// stores enough provenance for peers to trust the public half.
23pub struct AgentKey {
24    signing: SigningKey,
25    cert: CertChain,
26}
27
28impl AgentKey {
29    /// Issue a new agent key, signed by the given user.
30    ///
31    /// The user's private key is used exactly once here to sign
32    /// `(agent_pubkey || canonical_metadata_bytes)`, producing the
33    /// `issuer_sig` of the embedded [`CertChain`] (a root [`Issuer::User`]).
34    /// Use [`AgentKey::delegate`] to mint an *attenuated* sub-agent.
35    pub fn issue(user: &UserKey, metadata: AgentMetadata) -> Self {
36        let mut csprng = OsRng;
37        let signing = SigningKey::generate(&mut csprng);
38        let agent_pubkey_bytes: [u8; 32] = *signing.verifying_key().as_bytes();
39
40        let to_sign = sign_payload(&agent_pubkey_bytes, &metadata);
41        let sig = user.sign(&to_sign);
42
43        let cert = CertChain {
44            agent_pubkey: agent_pubkey_bytes,
45            metadata,
46            issuer: Issuer::User(user.public()),
47            issuer_sig: SerdeSig(sig),
48        };
49        Self { signing, cert }
50    }
51
52    /// Delegate a **sub-agent** key from this agent — attenuation-only.
53    ///
54    /// The child's caveats must be `⊑` this agent's caveats (the parent
55    /// authority), otherwise [`MeshError::CaveatAmplification`] is returned
56    /// and no key is minted. The sub-cert is signed by *this* agent's key and
57    /// embeds this agent's cert as its parent, so it roots at the same user
58    /// and every verifier re-checks attenuation at each link. A confused or
59    /// compromised agent therefore cannot mint a child with more authority
60    /// than it holds.
61    pub fn delegate(&self, metadata: AgentMetadata) -> Result<Self> {
62        if !metadata.caveats.leq(&self.cert.metadata.caveats) {
63            return Err(MeshError::CaveatAmplification);
64        }
65        let mut csprng = OsRng;
66        let signing = SigningKey::generate(&mut csprng);
67        let sub_pubkey: [u8; 32] = *signing.verifying_key().as_bytes();
68
69        let to_sign = sign_payload(&sub_pubkey, &metadata);
70        let sig = self.signing.sign(&to_sign);
71
72        let cert = CertChain {
73            agent_pubkey: sub_pubkey,
74            metadata,
75            issuer: Issuer::Agent {
76                pubkey: self.cert.agent_pubkey,
77                parent: Box::new(self.cert.clone()),
78            },
79            issuer_sig: SerdeSig(sig),
80        };
81        Ok(Self { signing, cert })
82    }
83
84    /// Sign a message with the agent's sub-key.
85    pub fn sign(&self, message: &[u8]) -> Signature {
86        self.signing.sign(message)
87    }
88
89    /// BLAKE3 fingerprint of the agent's public key bytes.
90    #[must_use]
91    pub fn fingerprint(&self) -> Fingerprint {
92        Fingerprint::of_bytes(&self.cert.agent_pubkey)
93    }
94
95    /// Borrow the cert chain proving this agent's authority.
96    #[must_use]
97    pub fn cert(&self) -> &CertChain {
98        &self.cert
99    }
100
101    /// Raw 32-byte ed25519 public key for this agent.
102    #[must_use]
103    pub fn public_bytes(&self) -> [u8; 32] {
104        self.cert.agent_pubkey
105    }
106
107    /// Expose the raw 32-byte ed25519 signing key bytes.
108    ///
109    /// This is the ONLY method that surfaces an agent's private bytes.
110    /// It exists for one reason: the transport layer
111    /// (`agent-mesh-transport`) needs to construct an `iroh` `SecretKey`
112    /// from the same ed25519 seed so the agent's pubkey doubles as its
113    /// iroh `EndpointId`. Callers must NOT persist or transmit these
114    /// bytes — the agent key is ephemeral by design.
115    #[must_use]
116    pub fn signing_key_bytes(&self) -> [u8; 32] {
117        self.signing.to_bytes()
118    }
119
120    /// Reconstruct an `AgentKey` from a 32-byte ed25519 seed and an
121    /// existing cert chain.
122    ///
123    /// Mirror of [`signing_key_bytes`](Self::signing_key_bytes): used
124    /// by the PyO3 bindings (and any FFI consumer) to ship an
125    /// `AgentKey` across a tokio-spawn boundary without forcing
126    /// `Clone` on the underlying ed25519 signing key. Returns
127    /// [`MeshError::BadSignature`] if the seed produces a public key
128    /// that doesn't match the cert chain's `agent_pubkey` — i.e.
129    /// rejects a forged pairing.
130    pub fn from_seed_and_cert(seed: &[u8; 32], cert: CertChain) -> Result<Self> {
131        let signing = ed25519_dalek::SigningKey::from_bytes(seed);
132        let derived_pub: [u8; 32] = *signing.verifying_key().as_bytes();
133        if derived_pub != cert.agent_pubkey {
134            return Err(MeshError::BadSignature);
135        }
136        Ok(Self { signing, cert })
137    }
138}
139
140/// Metadata claimed by an agent at certificate-issue time. These
141/// fields are signed by the user; they cannot be tampered with
142/// without invalidating the cert.
143#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
144pub struct AgentMetadata {
145    /// Role label — e.g. `"inference-worker"`, `"orchestrator"`.
146    pub role: String,
147    /// Host hint — e.g. `"host-a"`, `"host-b"`.
148    pub host: String,
149    /// Capabilities advertised to the mesh — e.g. `["ollama", "vllm"]`.
150    pub capabilities: Vec<String>,
151    /// Issue time, RFC 3339 string. Wall-clock is allowed here
152    /// because it's a *claim* in a signed cert, not a coordination
153    /// primitive.
154    pub issued_at: String,
155    /// Optional expiry, RFC 3339. `None` means the cert has no
156    /// declared expiry; consumers may still impose their own.
157    pub expires_at: Option<String>,
158    /// The agent's attenuated authority — the capability set it was minted
159    /// with. Part of the signed payload, so it cannot be tampered with
160    /// without invalidating the cert: a verifier can read these caveats and
161    /// trust them. Defaults to [`Caveats::top`] (unrestricted), which is the
162    /// back-compatible "no caveats declared" authority.
163    ///
164    /// [`AgentKey::delegate`] enforces `child ⊑ parent` at mint time, and
165    /// [`CertChain::verify`] re-checks attenuation at every link — so a chain
166    /// that amplifies authority is rejected even if each signature is valid
167    /// (see [`crate::caveats`] and issue #35).
168    #[serde(default)]
169    pub caveats: Caveats,
170}
171
172/// Who signed a [`CertChain`] — the trust anchor for that link.
173#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
174pub enum Issuer {
175    /// Root: signed directly by the user's key (the trust anchor).
176    User(UserPublic),
177    /// Delegated: signed by a parent agent. Carries the parent's full cert so
178    /// the chain roots at an [`Issuer::User`] and attenuation is checkable per
179    /// link.
180    Agent {
181        /// The parent agent's ed25519 public key. Must equal the embedded
182        /// `parent.agent_pubkey`.
183        pubkey: [u8; 32],
184        /// The parent's cert (recursively verifiable, attenuation-checked).
185        parent: Box<CertChain>,
186    },
187}
188
189/// The proof that this agent serves a specific user — directly (root) or
190/// through a chain of attenuating delegations.
191#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
192pub struct CertChain {
193    pub agent_pubkey: [u8; 32],
194    pub metadata: AgentMetadata,
195    /// Who signed this cert: the root user, or a parent agent + its cert.
196    pub issuer: Issuer,
197    /// The issuer's signature over `(agent_pubkey || metadata_bytes)`.
198    pub issuer_sig: SerdeSig,
199}
200
201impl CertChain {
202    /// Verify the cert chain end to end.
203    ///
204    /// - **Root** ([`Issuer::User`]): the user's key must have signed
205    ///   `(agent_pubkey || metadata_bytes)`.
206    /// - **Delegated** ([`Issuer::Agent`]): the named parent pubkey must match
207    ///   the embedded parent cert, that parent cert must itself verify
208    ///   (rooting at a user), the parent agent's key must have signed this
209    ///   cert, **and** this cert's caveats must be `⊑` the parent's
210    ///   ([`MeshError::CaveatAmplification`] otherwise). Attenuation is thus
211    ///   enforced structurally at every link — a forged or tampered chain that
212    ///   amplifies authority is rejected even if each signature is valid.
213    pub fn verify(&self) -> Result<()> {
214        let to_verify = sign_payload(&self.agent_pubkey, &self.metadata);
215        match &self.issuer {
216            Issuer::User(user) => {
217                // The user is the root authority (`⊤`); any caveats the agent
218                // declares are `⊑ ⊤`, so there is nothing to attenuate-check.
219                user.verify(&to_verify, &self.issuer_sig.0)
220            }
221            Issuer::Agent { pubkey, parent } => {
222                if *pubkey != parent.agent_pubkey {
223                    return Err(MeshError::InvalidCertChain(
224                        "delegated cert issuer pubkey does not match its parent".into(),
225                    ));
226                }
227                parent.verify()?;
228                verify_detached(pubkey, &to_verify, &self.issuer_sig.0)?;
229                if !self.metadata.caveats.leq(&parent.metadata.caveats) {
230                    return Err(MeshError::CaveatAmplification);
231                }
232                Ok(())
233            }
234        }
235    }
236
237    /// Fingerprint of the agent's public key.
238    #[must_use]
239    pub fn agent_fingerprint(&self) -> Fingerprint {
240        Fingerprint::of_bytes(&self.agent_pubkey)
241    }
242
243    /// Fingerprint of the **root user** this cert chains up to (walking through
244    /// any delegations). Unchanged for root certs.
245    #[must_use]
246    pub fn user_fingerprint(&self) -> Fingerprint {
247        match &self.issuer {
248            Issuer::User(user) => user.fingerprint(),
249            Issuer::Agent { parent, .. } => parent.user_fingerprint(),
250        }
251    }
252
253    /// The root user's public key this cert chains up to.
254    #[must_use]
255    pub fn root_user_pubkey(&self) -> UserPublic {
256        match &self.issuer {
257            Issuer::User(user) => user.clone(),
258            Issuer::Agent { parent, .. } => parent.root_user_pubkey(),
259        }
260    }
261}
262
263/// Newtype wrapping [`Signature`] so it can roundtrip through serde
264/// (the dalek type intentionally doesn't derive `Serialize`).
265#[derive(Debug, Clone, PartialEq, Eq)]
266pub struct SerdeSig(pub Signature);
267
268impl Serialize for SerdeSig {
269    fn serialize<S: serde::Serializer>(&self, ser: S) -> std::result::Result<S::Ok, S::Error> {
270        let bytes: [u8; 64] = self.0.to_bytes();
271        bytes.serialize(ser)
272    }
273}
274
275impl<'de> Deserialize<'de> for SerdeSig {
276    fn deserialize<D: serde::Deserializer<'de>>(de: D) -> std::result::Result<Self, D::Error> {
277        let bytes: Vec<u8> = Vec::deserialize(de)?;
278        if bytes.len() != 64 {
279            return Err(serde::de::Error::custom("expected 64-byte signature"));
280        }
281        let mut arr = [0u8; 64];
282        arr.copy_from_slice(&bytes);
283        Ok(Self(Signature::from_bytes(&arr)))
284    }
285}
286
287/// Canonical byte payload for cert signing/verification.
288fn sign_payload(agent_pubkey: &[u8; 32], metadata: &AgentMetadata) -> Vec<u8> {
289    let meta_bytes =
290        serde_json::to_vec(metadata).expect("AgentMetadata serializes deterministically");
291    let mut out = Vec::with_capacity(32 + meta_bytes.len());
292    out.extend_from_slice(agent_pubkey);
293    out.extend_from_slice(&meta_bytes);
294    out
295}
296
297/// Verify an ed25519 signature from a raw 32-byte public key (a parent
298/// agent's key, which — unlike a [`UserPublic`] — arrives as bare bytes).
299fn verify_detached(pubkey: &[u8; 32], msg: &[u8], sig: &Signature) -> Result<()> {
300    let vk = VerifyingKey::from_bytes(pubkey).map_err(|_| MeshError::BadSignature)?;
301    vk.verify_strict(msg, sig)
302        .map_err(|_| MeshError::BadSignature)
303}
304
305impl MeshError {
306    /// Helper used in tests to assert cert-chain failures uniformly.
307    #[cfg(test)]
308    pub(crate) fn is_bad_signature(&self) -> bool {
309        matches!(self, Self::BadSignature)
310    }
311}
312
313#[cfg(test)]
314mod tests {
315    use super::*;
316
317    fn fixture_metadata(role: &str) -> AgentMetadata {
318        AgentMetadata {
319            role: role.to_string(),
320            host: "test-host".to_string(),
321            capabilities: vec!["test".to_string()],
322            issued_at: "2026-05-28T12:00:00Z".to_string(),
323            expires_at: None,
324            caveats: Caveats::top(),
325        }
326    }
327
328    #[test]
329    fn issue_agent_key_signed_by_user() {
330        let user = UserKey::generate();
331        let agent = AgentKey::issue(&user, fixture_metadata("worker"));
332        assert_eq!(agent.cert().root_user_pubkey(), user.public());
333        assert_eq!(agent.cert().agent_pubkey, agent.public_bytes());
334    }
335
336    #[test]
337    fn verify_cert_chain_succeeds() {
338        let user = UserKey::generate();
339        let agent = AgentKey::issue(&user, fixture_metadata("worker"));
340        agent.cert().verify().expect("fresh cert verifies");
341    }
342
343    #[test]
344    fn tampered_metadata_fails_verify() {
345        let user = UserKey::generate();
346        let agent = AgentKey::issue(&user, fixture_metadata("worker"));
347        let mut cert = agent.cert().clone();
348        cert.metadata.role = "evil".to_string();
349        assert!(cert.verify().unwrap_err().is_bad_signature());
350    }
351
352    /// A fixture with no explicit caveats is unrestricted (`⊤`).
353    #[test]
354    fn fixture_caveats_default_to_top() {
355        assert_eq!(fixture_metadata("worker").caveats, Caveats::top());
356    }
357
358    /// Bounded caveats are part of the signed payload: they survive a serde
359    /// round-trip and the cert still verifies.
360    #[test]
361    fn bounded_caveats_roundtrip_and_verify() {
362        let mut meta = fixture_metadata("worker");
363        meta.caveats = Caveats {
364            exec: crate::Scope::only(["git".to_string()]),
365            max_calls: crate::CountBound::AtMost(8),
366            ..Caveats::top()
367        };
368        let user = UserKey::generate();
369        let agent = AgentKey::issue(&user, meta.clone());
370        agent.cert().verify().expect("fresh cert verifies");
371
372        let json = serde_json::to_string(agent.cert()).unwrap();
373        let parsed: CertChain = serde_json::from_str(&json).unwrap();
374        assert_eq!(parsed.metadata.caveats, meta.caveats);
375        parsed.verify().expect("roundtripped cert verifies");
376    }
377
378    /// Widening an agent's caveats after issue must invalidate the cert —
379    /// proof the caveats are signed, so a verifier can trust them.
380    #[test]
381    fn tampered_caveats_fails_verify() {
382        let mut meta = fixture_metadata("worker");
383        meta.caveats = Caveats {
384            exec: crate::Scope::only(["git".to_string()]),
385            ..Caveats::top()
386        };
387        let user = UserKey::generate();
388        let agent = AgentKey::issue(&user, meta);
389        let mut cert = agent.cert().clone();
390        cert.metadata.caveats = Caveats::top(); // amplify post-issue
391        assert!(cert.verify().unwrap_err().is_bad_signature());
392    }
393
394    /// Back-compat: metadata serialized without a `caveats` field (older wire
395    /// format) deserializes with `⊤` caveats via `#[serde(default)]`.
396    #[test]
397    fn absent_caveats_default_to_top() {
398        let json = r#"{"role":"w","host":"h","capabilities":[],"issued_at":"2026-05-28T00:00:00Z","expires_at":null}"#;
399        let meta: AgentMetadata = serde_json::from_str(json).unwrap();
400        assert_eq!(meta.caveats, Caveats::top());
401    }
402
403    #[test]
404    fn tampered_agent_pubkey_fails_verify() {
405        let user = UserKey::generate();
406        let agent = AgentKey::issue(&user, fixture_metadata("worker"));
407        let mut cert = agent.cert().clone();
408        cert.agent_pubkey[0] ^= 0xff;
409        assert!(cert.verify().unwrap_err().is_bad_signature());
410    }
411
412    #[test]
413    fn wrong_user_fails_verify() {
414        let user = UserKey::generate();
415        let other = UserKey::generate();
416        let agent = AgentKey::issue(&user, fixture_metadata("worker"));
417        let mut cert = agent.cert().clone();
418        cert.issuer = Issuer::User(other.public());
419        assert!(cert.verify().unwrap_err().is_bad_signature());
420    }
421
422    #[test]
423    fn serde_roundtrip_cert_chain() {
424        let user = UserKey::generate();
425        let agent = AgentKey::issue(&user, fixture_metadata("worker"));
426        let json = serde_json::to_string(agent.cert()).unwrap();
427        let parsed: CertChain = serde_json::from_str(&json).unwrap();
428        assert_eq!(&parsed, agent.cert());
429        parsed.verify().expect("roundtripped cert still verifies");
430    }
431
432    #[test]
433    fn fingerprints_match() {
434        let user = UserKey::generate();
435        let agent = AgentKey::issue(&user, fixture_metadata("worker"));
436        let cert = agent.cert();
437        assert_eq!(agent.fingerprint(), cert.agent_fingerprint());
438        assert_eq!(cert.user_fingerprint(), user.fingerprint());
439    }
440
441    #[test]
442    fn agent_sign_distinct_from_user_sign() {
443        let user = UserKey::generate();
444        let agent = AgentKey::issue(&user, fixture_metadata("worker"));
445        let user_sig = user.sign(b"x");
446        let agent_sig = agent.sign(b"x");
447        // distinct keys produce distinct signatures
448        assert_ne!(user_sig.to_bytes(), agent_sig.to_bytes());
449    }
450
451    #[test]
452    fn signing_key_bytes_roundtrip_signs_identically() {
453        use ed25519_dalek::Signer;
454        let user = UserKey::generate();
455        let agent = AgentKey::issue(&user, fixture_metadata("worker"));
456        let bytes = agent.signing_key_bytes();
457        assert_eq!(bytes.len(), 32);
458        // Rebuilding a SigningKey from those bytes must produce the
459        // same signature byte-for-byte — i.e. the seed roundtrips.
460        let rebuilt = ed25519_dalek::SigningKey::from_bytes(&bytes);
461        let msg = b"transport-layer-handshake";
462        let from_agent = agent.sign(msg);
463        let from_rebuilt = rebuilt.sign(msg);
464        assert_eq!(from_agent.to_bytes(), from_rebuilt.to_bytes());
465    }
466
467    #[test]
468    fn from_seed_and_cert_roundtrips() {
469        let user = UserKey::generate();
470        let agent = AgentKey::issue(&user, fixture_metadata("worker"));
471        let seed = agent.signing_key_bytes();
472        let cert = agent.cert().clone();
473        let rebuilt = AgentKey::from_seed_and_cert(&seed, cert).expect("seed+cert valid");
474        assert_eq!(rebuilt.fingerprint(), agent.fingerprint());
475        // And the rebuilt key signs identically (seed roundtrip).
476        let msg = b"rebuild-test";
477        assert_eq!(rebuilt.sign(msg).to_bytes(), agent.sign(msg).to_bytes());
478    }
479
480    #[test]
481    fn from_seed_and_cert_rejects_mismatched_pairing() {
482        let user = UserKey::generate();
483        let agent_a = AgentKey::issue(&user, fixture_metadata("a"));
484        let agent_b = AgentKey::issue(&user, fixture_metadata("b"));
485        // Pair B's seed with A's cert — must be rejected.
486        let res =
487            AgentKey::from_seed_and_cert(&agent_b.signing_key_bytes(), agent_a.cert().clone());
488        match res {
489            Ok(_) => panic!("mismatched pairing must reject"),
490            Err(e) => assert!(matches!(e, MeshError::BadSignature)),
491        }
492    }
493
494    #[test]
495    fn metadata_with_expiry_roundtrips() {
496        let mut meta = fixture_metadata("worker");
497        meta.expires_at = Some("2027-01-01T00:00:00Z".to_string());
498        let user = UserKey::generate();
499        let agent = AgentKey::issue(&user, meta.clone());
500        let cert = agent.cert();
501        assert_eq!(
502            cert.metadata.expires_at.as_deref(),
503            Some("2027-01-01T00:00:00Z")
504        );
505        let json = serde_json::to_string(cert).unwrap();
506        let parsed: CertChain = serde_json::from_str(&json).unwrap();
507        parsed.verify().unwrap();
508    }
509
510    // ── Delegation + attenuation enforcement (#35 phase 1c) ─────────────────
511
512    /// Metadata whose only restriction is the given exec allow-list.
513    fn meta_exec(role: &str, cmds: &[&str]) -> AgentMetadata {
514        AgentMetadata {
515            caveats: Caveats {
516                exec: crate::Scope::only(cmds.iter().map(|s| s.to_string())),
517                ..Caveats::top()
518            },
519            ..fixture_metadata(role)
520        }
521    }
522
523    #[test]
524    fn delegate_accepts_attenuation_and_roots_at_user() {
525        let user = UserKey::generate();
526        let parent = AgentKey::issue(&user, meta_exec("parent", &["git", "cargo"]));
527        // child exec {git} ⊑ parent {git, cargo}
528        let child = parent
529            .delegate(meta_exec("child", &["git"]))
530            .expect("attenuating delegation succeeds");
531        child.cert().verify().expect("delegated cert verifies");
532        assert_eq!(child.cert().user_fingerprint(), user.fingerprint());
533        assert_eq!(child.cert().root_user_pubkey(), user.public());
534    }
535
536    #[test]
537    fn delegate_rejects_amplification() {
538        let user = UserKey::generate();
539        let parent = AgentKey::issue(&user, meta_exec("parent", &["git"]));
540        // child wants ⊤ exec — strictly more than the parent's {git}.
541        let child = AgentMetadata {
542            caveats: Caveats::top(),
543            ..fixture_metadata("child")
544        };
545        assert!(matches!(
546            parent.delegate(child),
547            Err(MeshError::CaveatAmplification)
548        ));
549    }
550
551    #[test]
552    fn multi_level_delegation_attenuates_each_link() {
553        let user = UserKey::generate();
554        let a = AgentKey::issue(&user, meta_exec("a", &["git", "cargo"]));
555        let b = a.delegate(meta_exec("b", &["git"])).expect("B ⊑ A");
556        b.cert().verify().expect("B verifies through the chain");
557        assert_eq!(b.cert().user_fingerprint(), user.fingerprint());
558        // B cannot grant `rm`, which it never held.
559        assert!(matches!(
560            b.delegate(meta_exec("c", &["git", "rm"])),
561            Err(MeshError::CaveatAmplification)
562        ));
563    }
564
565    #[test]
566    fn delegated_cert_serde_roundtrips() {
567        let user = UserKey::generate();
568        let parent = AgentKey::issue(&user, meta_exec("parent", &["git"]));
569        let child = parent.delegate(meta_exec("child", &["git"])).unwrap();
570        let json = serde_json::to_string(child.cert()).unwrap();
571        let parsed: CertChain = serde_json::from_str(&json).unwrap();
572        assert_eq!(&parsed, child.cert());
573        parsed
574            .verify()
575            .expect("roundtripped delegated cert verifies");
576    }
577
578    #[test]
579    fn forged_amplifying_chain_fails_verify() {
580        // A compromised parent that signs a child granting MORE than it holds
581        // must still be rejected at verify time: attenuation is structural,
582        // not merely a mint-time courtesy in `delegate`.
583        let user = UserKey::generate();
584        let parent = AgentKey::issue(&user, meta_exec("parent", &["git"]));
585
586        // Hand-build a child with ⊤ caveats, signed correctly by the parent's
587        // key — i.e. bypassing `delegate`'s refusal.
588        let mut csprng = OsRng;
589        let child_signing = SigningKey::generate(&mut csprng);
590        let child_pubkey: [u8; 32] = *child_signing.verifying_key().as_bytes();
591        let child_meta = AgentMetadata {
592            caveats: Caveats::top(),
593            ..fixture_metadata("child")
594        };
595        let to_sign = sign_payload(&child_pubkey, &child_meta);
596        let sig = parent.sign(&to_sign); // a *valid* signature by the parent
597        let forged = CertChain {
598            agent_pubkey: child_pubkey,
599            metadata: child_meta,
600            issuer: Issuer::Agent {
601                pubkey: parent.public_bytes(),
602                parent: Box::new(parent.cert().clone()),
603            },
604            issuer_sig: SerdeSig(sig),
605        };
606        assert!(matches!(
607            forged.verify(),
608            Err(MeshError::CaveatAmplification)
609        ));
610    }
611
612    #[test]
613    fn delegated_issuer_pubkey_must_match_parent() {
614        // The issuer pubkey naming a different key than the embedded parent
615        // cert is a structural inconsistency and must be rejected.
616        let user = UserKey::generate();
617        let parent = AgentKey::issue(&user, meta_exec("parent", &["git"]));
618        let child = parent.delegate(meta_exec("child", &["git"])).unwrap();
619        let mut cert = child.cert().clone();
620        if let Issuer::Agent { pubkey, .. } = &mut cert.issuer {
621            pubkey[0] ^= 0xff;
622        }
623        assert!(matches!(cert.verify(), Err(MeshError::InvalidCertChain(_))));
624    }
625}