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