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, Scope};
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 rand::RngCore;
16use serde::{Deserialize, Serialize};
17
18/// Domain-separation tag for proof-of-possession signatures, so a PoP can never
19/// be confused with a cert-issue signature or any other signed payload (§9.2).
20const POP_DOMAIN: &[u8] = b"agent-mesh/possession-challenge/v1";
21
22/// A proof-of-possession challenge (§9.2). A certifier issues it for a `subject`
23/// pubkey; the holder of that subject's *private* key must sign
24/// [`signing_bytes`](PossessionChallenge::signing_bytes) to prove possession
25/// before the certifier will vouch for the pubkey. Binding both pubkeys + a
26/// fresh nonce stops a proof minted for one `(issuer, subject)` pair — or one
27/// session — from being replayed into another.
28#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
29pub struct PossessionChallenge {
30    /// The agent that issued this challenge and will sign the resulting cert.
31    pub issuer_pubkey: [u8; 32],
32    /// The externally-held pubkey whose possession must be proven.
33    pub subject_pubkey: [u8; 32],
34    /// Fresh random nonce — makes each challenge single-use.
35    pub nonce: [u8; 32],
36}
37
38impl PossessionChallenge {
39    /// The canonical, domain-separated bytes the **subject** signs to answer the
40    /// challenge. Returned for any holder (in-memory key, phone keystore, HSM) to
41    /// sign with whatever mechanism it has; the result is the PoP proof.
42    #[must_use]
43    pub fn signing_bytes(&self) -> Vec<u8> {
44        let mut v = Vec::with_capacity(POP_DOMAIN.len() + 96);
45        v.extend_from_slice(POP_DOMAIN);
46        v.extend_from_slice(&self.issuer_pubkey);
47        v.extend_from_slice(&self.subject_pubkey);
48        v.extend_from_slice(&self.nonce);
49        v
50    }
51}
52
53/// A short-lived per-agent keypair, signed by the user's root key.
54///
55/// `AgentKey` deliberately omits any save/load API: agent keys live
56/// in memory for the lifetime of the agent process and are
57/// regenerated on restart. The certificate ([`AgentKey::cert`])
58/// stores enough provenance for peers to trust the public half.
59pub struct AgentKey {
60    signing: SigningKey,
61    cert: CertChain,
62}
63
64impl AgentKey {
65    /// Issue a new agent key, signed by the given user.
66    ///
67    /// The user's private key is used exactly once here to sign
68    /// `(agent_pubkey || canonical_metadata_bytes)`, producing the
69    /// `issuer_sig` of the embedded [`CertChain`] (a root [`Issuer::User`]).
70    /// Use [`AgentKey::delegate`] to mint an *attenuated* sub-agent.
71    pub fn issue(user: &UserKey, metadata: AgentMetadata) -> Self {
72        let mut csprng = OsRng;
73        let signing = SigningKey::generate(&mut csprng);
74        let agent_pubkey_bytes: [u8; 32] = *signing.verifying_key().as_bytes();
75
76        let to_sign = sign_payload(&agent_pubkey_bytes, &metadata);
77        let sig = user.sign(&to_sign);
78
79        let cert = CertChain {
80            agent_pubkey: agent_pubkey_bytes,
81            metadata,
82            issuer: Issuer::User(user.public()),
83            issuer_sig: SerdeSig(sig),
84        };
85        Self { signing, cert }
86    }
87
88    /// Delegate a **sub-agent** key from this agent — attenuation-only.
89    ///
90    /// The child's caveats must be `⊑` this agent's caveats (the parent
91    /// authority), otherwise [`MeshError::CaveatAmplification`] is returned
92    /// and no key is minted. The sub-cert is signed by *this* agent's key and
93    /// embeds this agent's cert as its parent, so it roots at the same user
94    /// and every verifier re-checks attenuation at each link. A confused or
95    /// compromised agent therefore cannot mint a child with more authority
96    /// than it holds.
97    pub fn delegate(&self, metadata: AgentMetadata) -> Result<Self> {
98        let mut csprng = OsRng;
99        let signing = SigningKey::generate(&mut csprng);
100        let sub_pubkey: [u8; 32] = *signing.verifying_key().as_bytes();
101        let cert = self.certify_pubkey(sub_pubkey, metadata)?;
102        Ok(Self { signing, cert })
103    }
104
105    /// Issue a [`PossessionChallenge`] for certifying `subject_pubkey` under this
106    /// agent (§9.2). The certifier holds the returned challenge, sends it to the
107    /// subject out of band, and passes the subject's signed answer back into
108    /// [`delegate_external`](Self::delegate_external). The nonce is fresh, so each
109    /// challenge is single-use.
110    #[must_use]
111    pub fn possession_challenge(&self, subject_pubkey: [u8; 32]) -> PossessionChallenge {
112        let mut nonce = [0u8; 32];
113        OsRng.fill_bytes(&mut nonce);
114        PossessionChallenge {
115            issuer_pubkey: self.cert.agent_pubkey,
116            subject_pubkey,
117            nonce,
118        }
119    }
120
121    /// Certify an **externally-held** public key into a [`CertChain`] signed by
122    /// this agent — attenuation-only, *without* minting or holding the external
123    /// party's secret, and **only against a proof of possession** (§9.2).
124    ///
125    /// This is the seam phone enrollment needs. A worker agent (itself delegated
126    /// from a [`UserKey`]) can vouch for a phone's keystore-resident public key:
127    /// it produces a sub-cert rooted at the same user, with caveats `⊑` this
128    /// agent's authority, signed by this agent's key. The phone never reveals its
129    /// private key — it keeps its seed (or a non-exportable keystore handle) and
130    /// reconstructs its own [`AgentKey`] via
131    /// [`from_seed_and_cert`](Self::from_seed_and_cert).
132    ///
133    /// **Proof of possession.** `challenge` must have been issued by *this* agent
134    /// (via [`possession_challenge`](Self::possession_challenge)) and `proof` must
135    /// be the subject's signature over [`PossessionChallenge::signing_bytes`].
136    /// Without this, an agent could certify *any* pubkey — a victim's, or one it
137    /// does not control — which a cross-cloud join must never allow. The cert is
138    /// minted over `challenge.subject_pubkey`.
139    ///
140    /// # Errors
141    /// - [`MeshError::InvalidCertChain`] if `challenge.issuer_pubkey` is not this
142    ///   agent (a challenge it did not issue);
143    /// - [`MeshError::BadSignature`] if the PoP does not verify;
144    /// - [`MeshError::CaveatAmplification`] if `metadata`'s caveats are not `⊑`
145    ///   this agent's, exactly like [`delegate`](Self::delegate).
146    pub fn delegate_external(
147        &self,
148        challenge: &PossessionChallenge,
149        proof: &Signature,
150        metadata: AgentMetadata,
151    ) -> Result<CertChain> {
152        // The challenge must be one THIS agent issued — not attacker-chosen.
153        if challenge.issuer_pubkey != self.cert.agent_pubkey {
154            return Err(MeshError::InvalidCertChain(
155                "possession challenge was not issued by this agent".into(),
156            ));
157        }
158        // Proof of possession: the subject's private key must have signed the
159        // challenge. This is the §9.2 gate — certify only a pubkey whose holder
160        // proved possession.
161        verify_detached(&challenge.subject_pubkey, &challenge.signing_bytes(), proof)?;
162        self.certify_pubkey(challenge.subject_pubkey, metadata)
163    }
164
165    /// Shared cert-minting body for [`delegate`](Self::delegate) and
166    /// [`delegate_external`](Self::delegate_external): attenuation-check, then
167    /// sign `(pubkey || metadata)` with this agent's key, embedding this
168    /// agent's cert as the parent so the chain roots at the same user.
169    fn certify_pubkey(&self, pubkey: [u8; 32], metadata: AgentMetadata) -> Result<CertChain> {
170        if !metadata.caveats.leq(&self.cert.metadata.caveats) {
171            return Err(MeshError::CaveatAmplification);
172        }
173        let to_sign = sign_payload(&pubkey, &metadata);
174        let sig = self.signing.sign(&to_sign);
175        Ok(CertChain {
176            agent_pubkey: pubkey,
177            metadata,
178            issuer: Issuer::Agent {
179                pubkey: self.cert.agent_pubkey,
180                parent: Box::new(self.cert.clone()),
181            },
182            issuer_sig: SerdeSig(sig),
183        })
184    }
185
186    /// Sign a message with the agent's sub-key.
187    pub fn sign(&self, message: &[u8]) -> Signature {
188        self.signing.sign(message)
189    }
190
191    /// BLAKE3 fingerprint of the agent's public key bytes.
192    #[must_use]
193    pub fn fingerprint(&self) -> Fingerprint {
194        Fingerprint::of_bytes(&self.cert.agent_pubkey)
195    }
196
197    /// Borrow the cert chain proving this agent's authority.
198    #[must_use]
199    pub fn cert(&self) -> &CertChain {
200        &self.cert
201    }
202
203    /// Raw 32-byte ed25519 public key for this agent.
204    #[must_use]
205    pub fn public_bytes(&self) -> [u8; 32] {
206        self.cert.agent_pubkey
207    }
208
209    /// Expose the raw 32-byte ed25519 signing key bytes.
210    ///
211    /// This is the ONLY method that surfaces an agent's private bytes.
212    /// It exists for one reason: the transport layer
213    /// (`agent-mesh-transport`) needs to construct an `iroh` `SecretKey`
214    /// from the same ed25519 seed so the agent's pubkey doubles as its
215    /// iroh `EndpointId`. Callers must NOT persist or transmit these
216    /// bytes — the agent key is ephemeral by design.
217    #[must_use]
218    pub fn signing_key_bytes(&self) -> [u8; 32] {
219        self.signing.to_bytes()
220    }
221
222    /// Reconstruct an `AgentKey` from a 32-byte ed25519 seed and an
223    /// existing cert chain.
224    ///
225    /// Mirror of [`signing_key_bytes`](Self::signing_key_bytes): used
226    /// by the PyO3 bindings (and any FFI consumer) to ship an
227    /// `AgentKey` across a tokio-spawn boundary without forcing
228    /// `Clone` on the underlying ed25519 signing key. Returns
229    /// [`MeshError::BadSignature`] if the seed produces a public key
230    /// that doesn't match the cert chain's `agent_pubkey` — i.e.
231    /// rejects a forged pairing.
232    pub fn from_seed_and_cert(seed: &[u8; 32], cert: CertChain) -> Result<Self> {
233        let signing = ed25519_dalek::SigningKey::from_bytes(seed);
234        let derived_pub: [u8; 32] = *signing.verifying_key().as_bytes();
235        if derived_pub != cert.agent_pubkey {
236            return Err(MeshError::BadSignature);
237        }
238        Ok(Self { signing, cert })
239    }
240
241    /// The ed25519 verifying (public) half of this agent's key.
242    #[must_use]
243    pub fn verifying_key(&self) -> VerifyingKey {
244        self.signing.verifying_key()
245    }
246}
247
248/// `AgentKey` is the software (in-memory seed) [`MeshSigner`]: it signs with
249/// the seed it holds. This is today's behavior, surfaced through the trait so
250/// the envelope/transport layers can sign via `&dyn MeshSigner` and a future
251/// non-exportable keystore signer drops in at the same call sites.
252impl crate::signer::MeshSigner for AgentKey {
253    fn verifying_key(&self) -> VerifyingKey {
254        self.signing.verifying_key()
255    }
256
257    fn sign(&self, msg: &[u8]) -> Signature {
258        self.signing.sign(msg)
259    }
260}
261
262/// Metadata claimed by an agent at certificate-issue time. These
263/// fields are signed by the user; they cannot be tampered with
264/// without invalidating the cert.
265#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
266pub struct AgentMetadata {
267    /// Role label — e.g. `"inference-worker"`, `"orchestrator"`.
268    pub role: String,
269    /// Host hint — e.g. `"host-a"`, `"host-b"`.
270    pub host: String,
271    /// Capabilities advertised to the mesh — e.g. `["ollama", "vllm"]`.
272    pub capabilities: Vec<String>,
273    /// Issue time, RFC 3339 string. Wall-clock is allowed here
274    /// because it's a *claim* in a signed cert, not a coordination
275    /// primitive.
276    pub issued_at: String,
277    /// Optional expiry, RFC 3339. `None` means the cert has no
278    /// declared expiry; consumers may still impose their own.
279    pub expires_at: Option<String>,
280    /// The agent's attenuated authority — the capability set it was minted
281    /// with. Part of the signed payload, so it cannot be tampered with
282    /// without invalidating the cert: a verifier can read these caveats and
283    /// trust them. Defaults to [`Caveats::top`] (unrestricted), which is the
284    /// back-compatible "no caveats declared" authority.
285    ///
286    /// [`AgentKey::delegate`] enforces `child ⊑ parent` at mint time, and
287    /// [`CertChain::verify`] re-checks attenuation at every link — so a chain
288    /// that amplifies authority is rejected even if each signature is valid
289    /// (see [`crate::caveats`] and issue #35).
290    #[serde(default)]
291    pub caveats: Caveats,
292}
293
294/// Who signed a [`CertChain`] — the trust anchor for that link.
295#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
296pub enum Issuer {
297    /// Root: signed directly by the user's key (the trust anchor).
298    User(UserPublic),
299    /// Delegated: signed by a parent agent. Carries the parent's full cert so
300    /// the chain roots at an [`Issuer::User`] and attenuation is checkable per
301    /// link.
302    Agent {
303        /// The parent agent's ed25519 public key. Must equal the embedded
304        /// `parent.agent_pubkey`.
305        pubkey: [u8; 32],
306        /// The parent's cert (recursively verifiable, attenuation-checked).
307        parent: Box<CertChain>,
308    },
309}
310
311/// The proof that this agent serves a specific user — directly (root) or
312/// through a chain of attenuating delegations.
313#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
314pub struct CertChain {
315    pub agent_pubkey: [u8; 32],
316    pub metadata: AgentMetadata,
317    /// Who signed this cert: the root user, or a parent agent + its cert.
318    pub issuer: Issuer,
319    /// The issuer's signature over `(agent_pubkey || metadata_bytes)`.
320    pub issuer_sig: SerdeSig,
321}
322
323impl CertChain {
324    /// Verify the cert chain end to end.
325    ///
326    /// - **Root** ([`Issuer::User`]): the user's key must have signed
327    ///   `(agent_pubkey || metadata_bytes)`.
328    /// - **Delegated** ([`Issuer::Agent`]): the named parent pubkey must match
329    ///   the embedded parent cert, that parent cert must itself verify
330    ///   (rooting at a user), the parent agent's key must have signed this
331    ///   cert, **and** this cert's caveats must be `⊑` the parent's
332    ///   ([`MeshError::CaveatAmplification`] otherwise). Attenuation is thus
333    ///   enforced structurally at every link — a forged or tampered chain that
334    ///   amplifies authority is rejected even if each signature is valid.
335    pub fn verify(&self) -> Result<()> {
336        self.verify_inner(None)
337    }
338
339    /// Verify the cert chain against a known **current generation** (causal, not
340    /// wall-clock — §9.1). In addition to signature + attenuation at every link,
341    /// each link's `valid_for_generation` must include `current_generation`, else
342    /// the chain is refused ([`MeshError::Generation`]). This is the authoritative
343    /// revocation axis: bumping the generation invalidates every cert scoped to an
344    /// earlier one, pull-based and without any wall-clock.
345    pub fn verify_at(&self, current_generation: u64) -> Result<()> {
346        self.verify_inner(Some(current_generation))
347    }
348
349    /// Shared chain walk. `current_generation = None` is the context-free
350    /// [`CertChain::verify`]: it still enforces signatures + attenuation, but a
351    /// link that declares a *bounded* `valid_for_generation` is **refused**
352    /// (fail-closed) — a generation scope cannot be honoured without a current
353    /// generation, and silently ignoring it would be fail-open (the §9.1 hole).
354    fn verify_inner(&self, current_generation: Option<u64>) -> Result<()> {
355        // Generation gate for THIS link, fail-closed.
356        check_generation(
357            &self.metadata.caveats.valid_for_generation,
358            current_generation,
359        )?;
360        let to_verify = sign_payload(&self.agent_pubkey, &self.metadata);
361        match &self.issuer {
362            Issuer::User(user) => {
363                // The user is the root authority (`⊤`); any caveats the agent
364                // declares are `⊑ ⊤`, so there is nothing to attenuate-check.
365                user.verify(&to_verify, &self.issuer_sig.0)
366            }
367            Issuer::Agent { pubkey, parent } => {
368                if *pubkey != parent.agent_pubkey {
369                    return Err(MeshError::InvalidCertChain(
370                        "delegated cert issuer pubkey does not match its parent".into(),
371                    ));
372                }
373                parent.verify_inner(current_generation)?;
374                verify_detached(pubkey, &to_verify, &self.issuer_sig.0)?;
375                if !self.metadata.caveats.leq(&parent.metadata.caveats) {
376                    return Err(MeshError::CaveatAmplification);
377                }
378                Ok(())
379            }
380        }
381    }
382
383    /// Fingerprint of the agent's public key.
384    #[must_use]
385    pub fn agent_fingerprint(&self) -> Fingerprint {
386        Fingerprint::of_bytes(&self.agent_pubkey)
387    }
388
389    /// Fingerprint of the **root user** this cert chains up to (walking through
390    /// any delegations). Unchanged for root certs.
391    #[must_use]
392    pub fn user_fingerprint(&self) -> Fingerprint {
393        match &self.issuer {
394            Issuer::User(user) => user.fingerprint(),
395            Issuer::Agent { parent, .. } => parent.user_fingerprint(),
396        }
397    }
398
399    /// The root user's public key this cert chains up to.
400    #[must_use]
401    pub fn root_user_pubkey(&self) -> UserPublic {
402        match &self.issuer {
403            Issuer::User(user) => user.clone(),
404            Issuer::Agent { parent, .. } => parent.root_user_pubkey(),
405        }
406    }
407}
408
409/// Newtype wrapping [`Signature`] so it can roundtrip through serde
410/// (the dalek type intentionally doesn't derive `Serialize`).
411#[derive(Debug, Clone, PartialEq, Eq)]
412pub struct SerdeSig(pub Signature);
413
414impl Serialize for SerdeSig {
415    fn serialize<S: serde::Serializer>(&self, ser: S) -> std::result::Result<S::Ok, S::Error> {
416        let bytes: [u8; 64] = self.0.to_bytes();
417        bytes.serialize(ser)
418    }
419}
420
421impl<'de> Deserialize<'de> for SerdeSig {
422    fn deserialize<D: serde::Deserializer<'de>>(de: D) -> std::result::Result<Self, D::Error> {
423        let bytes: Vec<u8> = Vec::deserialize(de)?;
424        if bytes.len() != 64 {
425            return Err(serde::de::Error::custom("expected 64-byte signature"));
426        }
427        let mut arr = [0u8; 64];
428        arr.copy_from_slice(&bytes);
429        Ok(Self(Signature::from_bytes(&arr)))
430    }
431}
432
433/// Canonical byte payload for cert signing/verification.
434fn sign_payload(agent_pubkey: &[u8; 32], metadata: &AgentMetadata) -> Vec<u8> {
435    let meta_bytes =
436        serde_json::to_vec(metadata).expect("AgentMetadata serializes deterministically");
437    let mut out = Vec::with_capacity(32 + meta_bytes.len());
438    out.extend_from_slice(agent_pubkey);
439    out.extend_from_slice(&meta_bytes);
440    out
441}
442
443/// The causal-generation gate (§9.1, fail-closed). An unbounded scope (`All`)
444/// always passes. A *bounded* scope passes only when a `current` generation is
445/// supplied **and** lies within it; with no context it is REFUSED — a scope that
446/// cannot be checked must never be silently ignored (that was the fail-open hole).
447fn check_generation(scope: &Scope<u64>, current: Option<u64>) -> Result<()> {
448    match scope {
449        Scope::All => Ok(()),
450        Scope::Only(gens) => match current {
451            None => Err(MeshError::Generation(
452                "cert is generation-scoped; verify with a current generation via verify_at()"
453                    .into(),
454            )),
455            Some(g) if gens.contains(&g) => Ok(()),
456            Some(g) => Err(MeshError::Generation(format!(
457                "cert is not valid for generation {g}"
458            ))),
459        },
460    }
461}
462
463/// Verify an ed25519 signature from a raw 32-byte public key (a parent
464/// agent's key, which — unlike a [`UserPublic`] — arrives as bare bytes).
465fn verify_detached(pubkey: &[u8; 32], msg: &[u8], sig: &Signature) -> Result<()> {
466    let vk = VerifyingKey::from_bytes(pubkey).map_err(|_| MeshError::BadSignature)?;
467    vk.verify_strict(msg, sig)
468        .map_err(|_| MeshError::BadSignature)
469}
470
471impl MeshError {
472    /// Helper used in tests to assert cert-chain failures uniformly.
473    #[cfg(test)]
474    pub(crate) fn is_bad_signature(&self) -> bool {
475        matches!(self, Self::BadSignature)
476    }
477}
478
479#[cfg(test)]
480mod tests {
481    use super::*;
482
483    fn fixture_metadata(role: &str) -> AgentMetadata {
484        AgentMetadata {
485            role: role.to_string(),
486            host: "test-host".to_string(),
487            capabilities: vec!["test".to_string()],
488            issued_at: "2026-05-28T12:00:00Z".to_string(),
489            expires_at: None,
490            caveats: Caveats::top(),
491        }
492    }
493
494    #[test]
495    fn issue_agent_key_signed_by_user() {
496        let user = UserKey::generate();
497        let agent = AgentKey::issue(&user, fixture_metadata("worker"));
498        assert_eq!(agent.cert().root_user_pubkey(), user.public());
499        assert_eq!(agent.cert().agent_pubkey, agent.public_bytes());
500    }
501
502    #[test]
503    fn verify_cert_chain_succeeds() {
504        let user = UserKey::generate();
505        let agent = AgentKey::issue(&user, fixture_metadata("worker"));
506        agent.cert().verify().expect("fresh cert verifies");
507    }
508
509    /// §9.1: an unbounded (`All`) generation scope — the default — verifies both
510    /// context-free and at any generation. No regression for today's certs.
511    #[test]
512    fn unbounded_generation_verifies_in_any_context() {
513        let user = UserKey::generate();
514        let agent = AgentKey::issue(&user, fixture_metadata("worker"));
515        agent.cert().verify().expect("context-free ok");
516        agent.cert().verify_at(0).expect("gen 0 ok");
517        agent.cert().verify_at(9_999).expect("any gen ok");
518    }
519
520    /// §9.1 (the core fix): a generation-SCOPED cert is REFUSED by context-free
521    /// `verify()` (the scope can't be checked ⇒ fail-closed, not ignored), passes
522    /// `verify_at` for an in-scope generation, and is refused out of scope.
523    #[test]
524    fn generation_scoped_cert_is_fail_closed_context_free() {
525        let mut meta = fixture_metadata("worker");
526        meta.caveats = Caveats {
527            valid_for_generation: crate::Scope::only([5u64]),
528            ..Caveats::top()
529        };
530        let user = UserKey::generate();
531        let agent = AgentKey::issue(&user, meta);
532
533        // Context-free: cannot check the scope → REFUSE (was silently ignored).
534        assert!(matches!(
535            agent.cert().verify().unwrap_err(),
536            MeshError::Generation(_)
537        ));
538        // With the right generation: passes.
539        agent.cert().verify_at(5).expect("valid for generation 5");
540        // With a different generation: refused (revoked by generation bump).
541        assert!(matches!(
542            agent.cert().verify_at(6).unwrap_err(),
543            MeshError::Generation(_)
544        ));
545    }
546
547    /// The gate applies at EVERY link: a delegated child whose chain includes a
548    /// generation-scoped link is refused context-free and checked against the
549    /// supplied generation across the whole chain.
550    #[test]
551    fn generation_gate_applies_across_the_chain() {
552        let scoped = |role: &str| AgentMetadata {
553            caveats: Caveats {
554                valid_for_generation: crate::Scope::only([5u64]),
555                ..Caveats::top()
556            },
557            ..fixture_metadata(role)
558        };
559        let user = UserKey::generate();
560        let parent = AgentKey::issue(&user, scoped("lead"));
561        let child = parent
562            .delegate(scoped("worker"))
563            .expect("attenuating delegate");
564
565        assert!(matches!(
566            child.cert().verify().unwrap_err(),
567            MeshError::Generation(_)
568        ));
569        child
570            .cert()
571            .verify_at(5)
572            .expect("chain valid for generation 5");
573        assert!(matches!(
574            child.cert().verify_at(6).unwrap_err(),
575            MeshError::Generation(_)
576        ));
577    }
578
579    #[test]
580    fn tampered_metadata_fails_verify() {
581        let user = UserKey::generate();
582        let agent = AgentKey::issue(&user, fixture_metadata("worker"));
583        let mut cert = agent.cert().clone();
584        cert.metadata.role = "evil".to_string();
585        assert!(cert.verify().unwrap_err().is_bad_signature());
586    }
587
588    /// A fixture with no explicit caveats is unrestricted (`⊤`).
589    #[test]
590    fn fixture_caveats_default_to_top() {
591        assert_eq!(fixture_metadata("worker").caveats, Caveats::top());
592    }
593
594    /// Bounded caveats are part of the signed payload: they survive a serde
595    /// round-trip and the cert still verifies.
596    #[test]
597    fn bounded_caveats_roundtrip_and_verify() {
598        let mut meta = fixture_metadata("worker");
599        meta.caveats = Caveats {
600            exec: crate::Scope::only(["git".to_string()]),
601            max_calls: crate::CountBound::AtMost(8),
602            ..Caveats::top()
603        };
604        let user = UserKey::generate();
605        let agent = AgentKey::issue(&user, meta.clone());
606        agent.cert().verify().expect("fresh cert verifies");
607
608        let json = serde_json::to_string(agent.cert()).unwrap();
609        let parsed: CertChain = serde_json::from_str(&json).unwrap();
610        assert_eq!(parsed.metadata.caveats, meta.caveats);
611        parsed.verify().expect("roundtripped cert verifies");
612    }
613
614    /// Widening an agent's caveats after issue must invalidate the cert —
615    /// proof the caveats are signed, so a verifier can trust them.
616    #[test]
617    fn tampered_caveats_fails_verify() {
618        let mut meta = fixture_metadata("worker");
619        meta.caveats = Caveats {
620            exec: crate::Scope::only(["git".to_string()]),
621            ..Caveats::top()
622        };
623        let user = UserKey::generate();
624        let agent = AgentKey::issue(&user, meta);
625        let mut cert = agent.cert().clone();
626        cert.metadata.caveats = Caveats::top(); // amplify post-issue
627        assert!(cert.verify().unwrap_err().is_bad_signature());
628    }
629
630    /// Back-compat: metadata serialized without a `caveats` field (older wire
631    /// format) deserializes with `⊤` caveats via `#[serde(default)]`.
632    #[test]
633    fn absent_caveats_default_to_top() {
634        let json = r#"{"role":"w","host":"h","capabilities":[],"issued_at":"2026-05-28T00:00:00Z","expires_at":null}"#;
635        let meta: AgentMetadata = serde_json::from_str(json).unwrap();
636        assert_eq!(meta.caveats, Caveats::top());
637    }
638
639    #[test]
640    fn tampered_agent_pubkey_fails_verify() {
641        let user = UserKey::generate();
642        let agent = AgentKey::issue(&user, fixture_metadata("worker"));
643        let mut cert = agent.cert().clone();
644        cert.agent_pubkey[0] ^= 0xff;
645        assert!(cert.verify().unwrap_err().is_bad_signature());
646    }
647
648    #[test]
649    fn wrong_user_fails_verify() {
650        let user = UserKey::generate();
651        let other = UserKey::generate();
652        let agent = AgentKey::issue(&user, fixture_metadata("worker"));
653        let mut cert = agent.cert().clone();
654        cert.issuer = Issuer::User(other.public());
655        assert!(cert.verify().unwrap_err().is_bad_signature());
656    }
657
658    #[test]
659    fn serde_roundtrip_cert_chain() {
660        let user = UserKey::generate();
661        let agent = AgentKey::issue(&user, fixture_metadata("worker"));
662        let json = serde_json::to_string(agent.cert()).unwrap();
663        let parsed: CertChain = serde_json::from_str(&json).unwrap();
664        assert_eq!(&parsed, agent.cert());
665        parsed.verify().expect("roundtripped cert still verifies");
666    }
667
668    #[test]
669    fn fingerprints_match() {
670        let user = UserKey::generate();
671        let agent = AgentKey::issue(&user, fixture_metadata("worker"));
672        let cert = agent.cert();
673        assert_eq!(agent.fingerprint(), cert.agent_fingerprint());
674        assert_eq!(cert.user_fingerprint(), user.fingerprint());
675    }
676
677    #[test]
678    fn agent_sign_distinct_from_user_sign() {
679        let user = UserKey::generate();
680        let agent = AgentKey::issue(&user, fixture_metadata("worker"));
681        let user_sig = user.sign(b"x");
682        let agent_sig = agent.sign(b"x");
683        // distinct keys produce distinct signatures
684        assert_ne!(user_sig.to_bytes(), agent_sig.to_bytes());
685    }
686
687    #[test]
688    fn signing_key_bytes_roundtrip_signs_identically() {
689        use ed25519_dalek::Signer;
690        let user = UserKey::generate();
691        let agent = AgentKey::issue(&user, fixture_metadata("worker"));
692        let bytes = agent.signing_key_bytes();
693        assert_eq!(bytes.len(), 32);
694        // Rebuilding a SigningKey from those bytes must produce the
695        // same signature byte-for-byte — i.e. the seed roundtrips.
696        let rebuilt = ed25519_dalek::SigningKey::from_bytes(&bytes);
697        let msg = b"transport-layer-handshake";
698        let from_agent = agent.sign(msg);
699        let from_rebuilt = rebuilt.sign(msg);
700        assert_eq!(from_agent.to_bytes(), from_rebuilt.to_bytes());
701    }
702
703    #[test]
704    fn from_seed_and_cert_roundtrips() {
705        let user = UserKey::generate();
706        let agent = AgentKey::issue(&user, fixture_metadata("worker"));
707        let seed = agent.signing_key_bytes();
708        let cert = agent.cert().clone();
709        let rebuilt = AgentKey::from_seed_and_cert(&seed, cert).expect("seed+cert valid");
710        assert_eq!(rebuilt.fingerprint(), agent.fingerprint());
711        // And the rebuilt key signs identically (seed roundtrip).
712        let msg = b"rebuild-test";
713        assert_eq!(rebuilt.sign(msg).to_bytes(), agent.sign(msg).to_bytes());
714    }
715
716    #[test]
717    fn from_seed_and_cert_rejects_mismatched_pairing() {
718        let user = UserKey::generate();
719        let agent_a = AgentKey::issue(&user, fixture_metadata("a"));
720        let agent_b = AgentKey::issue(&user, fixture_metadata("b"));
721        // Pair B's seed with A's cert — must be rejected.
722        let res =
723            AgentKey::from_seed_and_cert(&agent_b.signing_key_bytes(), agent_a.cert().clone());
724        match res {
725            Ok(_) => panic!("mismatched pairing must reject"),
726            Err(e) => assert!(matches!(e, MeshError::BadSignature)),
727        }
728    }
729
730    #[test]
731    fn metadata_with_expiry_roundtrips() {
732        let mut meta = fixture_metadata("worker");
733        meta.expires_at = Some("2027-01-01T00:00:00Z".to_string());
734        let user = UserKey::generate();
735        let agent = AgentKey::issue(&user, meta.clone());
736        let cert = agent.cert();
737        assert_eq!(
738            cert.metadata.expires_at.as_deref(),
739            Some("2027-01-01T00:00:00Z")
740        );
741        let json = serde_json::to_string(cert).unwrap();
742        let parsed: CertChain = serde_json::from_str(&json).unwrap();
743        parsed.verify().unwrap();
744    }
745
746    // ── Delegation + attenuation enforcement (#35 phase 1c) ─────────────────
747
748    /// Metadata whose only restriction is the given exec allow-list.
749    fn meta_exec(role: &str, cmds: &[&str]) -> AgentMetadata {
750        AgentMetadata {
751            caveats: Caveats {
752                exec: crate::Scope::only(cmds.iter().map(|s| s.to_string())),
753                ..Caveats::top()
754            },
755            ..fixture_metadata(role)
756        }
757    }
758
759    #[test]
760    fn delegate_accepts_attenuation_and_roots_at_user() {
761        let user = UserKey::generate();
762        let parent = AgentKey::issue(&user, meta_exec("parent", &["git", "cargo"]));
763        // child exec {git} ⊑ parent {git, cargo}
764        let child = parent
765            .delegate(meta_exec("child", &["git"]))
766            .expect("attenuating delegation succeeds");
767        child.cert().verify().expect("delegated cert verifies");
768        assert_eq!(child.cert().user_fingerprint(), user.fingerprint());
769        assert_eq!(child.cert().root_user_pubkey(), user.public());
770    }
771
772    #[test]
773    fn delegate_rejects_amplification() {
774        let user = UserKey::generate();
775        let parent = AgentKey::issue(&user, meta_exec("parent", &["git"]));
776        // child wants ⊤ exec — strictly more than the parent's {git}.
777        let child = AgentMetadata {
778            caveats: Caveats::top(),
779            ..fixture_metadata("child")
780        };
781        assert!(matches!(
782            parent.delegate(child),
783            Err(MeshError::CaveatAmplification)
784        ));
785    }
786
787    #[test]
788    fn multi_level_delegation_attenuates_each_link() {
789        let user = UserKey::generate();
790        let a = AgentKey::issue(&user, meta_exec("a", &["git", "cargo"]));
791        let b = a.delegate(meta_exec("b", &["git"])).expect("B ⊑ A");
792        b.cert().verify().expect("B verifies through the chain");
793        assert_eq!(b.cert().user_fingerprint(), user.fingerprint());
794        // B cannot grant `rm`, which it never held.
795        assert!(matches!(
796            b.delegate(meta_exec("c", &["git", "rm"])),
797            Err(MeshError::CaveatAmplification)
798        ));
799    }
800
801    #[test]
802    fn delegated_cert_serde_roundtrips() {
803        let user = UserKey::generate();
804        let parent = AgentKey::issue(&user, meta_exec("parent", &["git"]));
805        let child = parent.delegate(meta_exec("child", &["git"])).unwrap();
806        let json = serde_json::to_string(child.cert()).unwrap();
807        let parsed: CertChain = serde_json::from_str(&json).unwrap();
808        assert_eq!(&parsed, child.cert());
809        parsed
810            .verify()
811            .expect("roundtripped delegated cert verifies");
812    }
813
814    #[test]
815    fn forged_amplifying_chain_fails_verify() {
816        // A compromised parent that signs a child granting MORE than it holds
817        // must still be rejected at verify time: attenuation is structural,
818        // not merely a mint-time courtesy in `delegate`.
819        let user = UserKey::generate();
820        let parent = AgentKey::issue(&user, meta_exec("parent", &["git"]));
821
822        // Hand-build a child with ⊤ caveats, signed correctly by the parent's
823        // key — i.e. bypassing `delegate`'s refusal.
824        let mut csprng = OsRng;
825        let child_signing = SigningKey::generate(&mut csprng);
826        let child_pubkey: [u8; 32] = *child_signing.verifying_key().as_bytes();
827        let child_meta = AgentMetadata {
828            caveats: Caveats::top(),
829            ..fixture_metadata("child")
830        };
831        let to_sign = sign_payload(&child_pubkey, &child_meta);
832        let sig = parent.sign(&to_sign); // a *valid* signature by the parent
833        let forged = CertChain {
834            agent_pubkey: child_pubkey,
835            metadata: child_meta,
836            issuer: Issuer::Agent {
837                pubkey: parent.public_bytes(),
838                parent: Box::new(parent.cert().clone()),
839            },
840            issuer_sig: SerdeSig(sig),
841        };
842        assert!(matches!(
843            forged.verify(),
844            Err(MeshError::CaveatAmplification)
845        ));
846    }
847
848    // ── External-pubkey delegation (phone enrollment, PR #202 P1) ───────────
849
850    /// A worker certifies an EXTERNAL pubkey (the phone's) into a cert chain it
851    /// does NOT hold the secret for. The phone reconstructs its own `AgentKey`
852    /// from its OWN seed + the returned cert, signs an envelope, and that
853    /// envelope verifies and roots at the user — with caveats attenuated at
854    /// each link (phone ⊑ worker ⊑ user).
855    #[test]
856    fn delegate_external_certifies_phone_and_roots_at_user() {
857        use crate::envelope::{Recipient, SignedEnvelope};
858
859        let user = UserKey::generate();
860        let worker = AgentKey::issue(&user, meta_exec("worker", &["git", "cargo"]));
861
862        // The phone holds its OWN seed; the worker only ever sees the pubkey.
863        let phone_seed = [42u8; 32];
864        let phone_signing = SigningKey::from_bytes(&phone_seed);
865        let phone_vk = phone_signing.verifying_key();
866
867        // Worker issues a PoP challenge; the phone proves possession by signing it.
868        let challenge = worker.possession_challenge(*phone_vk.as_bytes());
869        let proof = phone_signing.sign(&challenge.signing_bytes());
870        // Worker vouches for the phone's pubkey, attenuating to {git}.
871        let phone_cert = worker
872            .delegate_external(&challenge, &proof, meta_exec("phone", &["git"]))
873            .expect("external delegation attenuates");
874
875        // The cert names the phone's pubkey (NOT a freshly minted one).
876        assert_eq!(phone_cert.agent_pubkey, *phone_vk.as_bytes());
877        phone_cert.verify().expect("external cert verifies");
878        assert_eq!(phone_cert.root_user_pubkey(), user.public());
879        assert_eq!(phone_cert.user_fingerprint(), user.fingerprint());
880
881        // Phone reconstructs its AgentKey from its own seed + the cert.
882        let phone =
883            AgentKey::from_seed_and_cert(&phone_seed, phone_cert).expect("phone seed matches cert");
884
885        // Phone signs an envelope; it verifies end-to-end.
886        let env = SignedEnvelope::new(
887            &phone,
888            Recipient::Topic {
889                name: "drake/work".into(),
890            },
891            1,
892            b"from-the-phone".to_vec(),
893        );
894        env.verify().expect("phone envelope verifies");
895        assert_eq!(env.sender_user_fp(), user.fingerprint());
896
897        // Attenuation: phone {git} ⊑ worker {git,cargo} ⊑ user ⊤.
898        assert!(phone
899            .cert()
900            .metadata
901            .caveats
902            .leq(&worker.cert().metadata.caveats));
903    }
904
905    /// A cert built over one external pubkey must NOT validate a DIFFERENT
906    /// holder: pairing a mismatched seed with the cert is rejected, and a cert
907    /// whose `agent_pubkey` is swapped fails signature verification.
908    #[test]
909    fn delegate_external_rejects_wrong_pubkey() {
910        let user = UserKey::generate();
911        let worker = AgentKey::issue(&user, meta_exec("worker", &["git"]));
912
913        let phone_seed = [42u8; 32];
914        let phone_signing = SigningKey::from_bytes(&phone_seed);
915        let phone_vk = phone_signing.verifying_key();
916        let challenge = worker.possession_challenge(*phone_vk.as_bytes());
917        let proof = phone_signing.sign(&challenge.signing_bytes());
918        let cert = worker
919            .delegate_external(&challenge, &proof, meta_exec("phone", &["git"]))
920            .unwrap();
921
922        // A DIFFERENT seed cannot reconstruct an AgentKey from this cert.
923        let other_seed = [99u8; 32];
924        assert!(matches!(
925            AgentKey::from_seed_and_cert(&other_seed, cert.clone()),
926            Err(MeshError::BadSignature)
927        ));
928
929        // Swapping the certified pubkey breaks the issuer signature.
930        let mut tampered = cert;
931        tampered.agent_pubkey[0] ^= 0xff;
932        assert!(matches!(
933            tampered.verify(),
934            Err(MeshError::BadSignature) | Err(MeshError::CaveatAmplification)
935        ));
936    }
937
938    /// External delegation is attenuation-only: a phone that asks for MORE than
939    /// the worker holds is rejected, just like `delegate`.
940    #[test]
941    fn delegate_external_rejects_amplification() {
942        let user = UserKey::generate();
943        let worker = AgentKey::issue(&user, meta_exec("worker", &["git"]));
944        let phone_signing = SigningKey::from_bytes(&[42u8; 32]);
945        let phone_vk = phone_signing.verifying_key();
946        let challenge = worker.possession_challenge(*phone_vk.as_bytes());
947        let proof = phone_signing.sign(&challenge.signing_bytes());
948        // Phone requests ⊤ exec — strictly more than worker's {git}. PoP passes,
949        // so the request reaches — and is refused by — the attenuation check.
950        let amplifying = AgentMetadata {
951            caveats: Caveats::top(),
952            ..fixture_metadata("phone")
953        };
954        assert!(matches!(
955            worker.delegate_external(&challenge, &proof, amplifying),
956            Err(MeshError::CaveatAmplification)
957        ));
958    }
959
960    /// §9.2: certifying a pubkey whose holder did NOT prove possession is refused.
961    /// A proof from the WRONG key fails — so an agent can't certify a victim's
962    /// pubkey (the proof must come from the holder of the subject's private key).
963    #[test]
964    fn delegate_external_requires_proof_of_possession() {
965        let user = UserKey::generate();
966        let worker = AgentKey::issue(&user, meta_exec("worker", &["git"]));
967        let victim_vk = SigningKey::from_bytes(&[7u8; 32]).verifying_key();
968        let challenge = worker.possession_challenge(*victim_vk.as_bytes());
969        // An attacker signs with a DIFFERENT key (they don't hold the victim's).
970        let attacker = SigningKey::from_bytes(&[9u8; 32]);
971        let forged = attacker.sign(&challenge.signing_bytes());
972        assert!(matches!(
973            worker.delegate_external(&challenge, &forged, meta_exec("v", &["git"])),
974            Err(MeshError::BadSignature)
975        ));
976    }
977
978    /// A challenge issued by a DIFFERENT agent is refused: the proof must answer a
979    /// challenge THIS certifier minted (no relay of a foreign challenge).
980    #[test]
981    fn delegate_external_rejects_a_foreign_challenge() {
982        let user = UserKey::generate();
983        let worker = AgentKey::issue(&user, meta_exec("worker", &["git"]));
984        let other = AgentKey::issue(&user, meta_exec("other", &["git"]));
985        let phone_signing = SigningKey::from_bytes(&[42u8; 32]);
986        let phone_vk = phone_signing.verifying_key();
987        // Challenge minted by `other`, presented to `worker`.
988        let foreign = other.possession_challenge(*phone_vk.as_bytes());
989        let proof = phone_signing.sign(&foreign.signing_bytes());
990        assert!(matches!(
991            worker.delegate_external(&foreign, &proof, meta_exec("phone", &["git"])),
992            Err(MeshError::InvalidCertChain(_))
993        ));
994    }
995
996    #[test]
997    fn delegated_issuer_pubkey_must_match_parent() {
998        // The issuer pubkey naming a different key than the embedded parent
999        // cert is a structural inconsistency and must be rejected.
1000        let user = UserKey::generate();
1001        let parent = AgentKey::issue(&user, meta_exec("parent", &["git"]));
1002        let child = parent.delegate(meta_exec("child", &["git"])).unwrap();
1003        let mut cert = child.cert().clone();
1004        if let Issuer::Agent { pubkey, .. } = &mut cert.issuer {
1005            pubkey[0] ^= 0xff;
1006        }
1007        assert!(matches!(cert.verify(), Err(MeshError::InvalidCertChain(_))));
1008    }
1009}