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