Skip to main content

agent_mesh_protocol/
assertion.rs

1//! The **Assertion** proof: a detached author signature over a [`ContentId`].
2//!
3//! It answers *who vouched for this object?* and lives **beside** the node,
4//! never inside its hashed bytes (see [`crate::authority`], proof table). The
5//! signed message is a domain tag plus the CID's own byte form — no bespoke
6//! canonical encoding. The assertion does not carry the CID: the reader
7//! supplies the CID they computed, so a signature cannot vouch for anything
8//! but what the reader actually holds.
9
10use content_addressable::ContentId;
11use ed25519_dalek::VerifyingKey;
12use serde::{Deserialize, Serialize};
13
14use crate::agent_key::{AgentKey, CertChain, Issuer, SerdeSig};
15use crate::UserPublic;
16use crate::{MeshError, Result};
17
18/// Domain tag: a signature minted here can't be replayed as any other signature.
19const ASSERTION_DOMAIN: &[u8] = b"agent-mesh/provenance/assertion/v1\0";
20
21/// An author's detached signature over a [`ContentId`], with the certificate
22/// chain that lets any reader check who the author is.
23#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
24pub struct Assertion {
25    author: CertChain,
26    sig: SerdeSig,
27}
28
29fn message(cid: &ContentId) -> Vec<u8> {
30    let mut m = ASSERTION_DOMAIN.to_vec();
31    m.extend_from_slice(&cid.to_bytes());
32    m
33}
34
35impl Assertion {
36    /// `author` vouches for `cid`.
37    #[must_use]
38    pub fn sign(author: &AgentKey, cid: &ContentId) -> Self {
39        Self {
40            author: author.cert().clone(),
41            sig: SerdeSig(author.sign(&message(cid))),
42        }
43    }
44
45    /// The author's certificate chain.
46    #[must_use]
47    pub fn author(&self) -> &CertChain {
48        &self.author
49    }
50
51    /// The detached signature alone, for a record that cites the author's
52    /// certificate instead of carrying it.
53    #[must_use]
54    pub fn signature(&self) -> &SerdeSig {
55        &self.sig
56    }
57
58    /// An assertion from a certificate chain and a signature kept apart. The
59    /// pair is not checked here: [`Assertion::verify`] is what says whether
60    /// `sig` is `author`'s.
61    #[must_use]
62    pub fn from_parts(author: CertChain, sig: SerdeSig) -> Self {
63        Self { author, sig }
64    }
65
66    /// Check that the author signed `cid` and that the author's chain passes
67    /// is **rooted** at `trust_root` (the flock server's agent public key) at epoch
68    /// `generation`: the trust root must be the root agent, the certificate
69    /// issued by the user. An intermediate or leaf key matching is refused.
70    /// Two servers of one user share a user key but not an agent key.
71    ///
72    /// # Errors
73    /// [`MeshError::BadSignature`] if the signature does not match `cid`;
74    /// [`MeshError::InvalidCertChain`] / generation / amplification errors if
75    /// the chain is bad or its root agent is not `trust_root`.
76    pub fn verify(&self, cid: &ContentId, trust_root: &[u8; 32], generation: u64) -> Result<()> {
77        self.author.verify_at(generation)?;
78        if root_agent(&self.author) != trust_root {
79            return Err(MeshError::InvalidCertChain(
80                "author chain is not rooted at the trust root".into(),
81            ));
82        }
83        VerifyingKey::from_bytes(&self.author.agent_pubkey)
84            .and_then(|vk| vk.verify_strict(&message(cid), &self.sig.0))
85            .map_err(|_| MeshError::BadSignature)
86    }
87
88    /// Check that the author signed `cid` with a key the user `user` certified
89    /// directly (a device key: the chain is one link, issued by that user) at
90    /// epoch `generation`. Which devices count is the caller's to decide.
91    ///
92    /// # Errors
93    /// [`MeshError::InvalidCertChain`] if the chain is not issued directly by
94    /// `user` or fails verification; [`MeshError::BadSignature`] if the
95    /// signature does not match `cid`.
96    pub fn verify_user(&self, cid: &ContentId, user: &UserPublic, generation: u64) -> Result<()> {
97        self.author.verify_at(generation)?;
98        match &self.author.issuer {
99            Issuer::User(u) if u == user => {}
100            _ => {
101                return Err(MeshError::InvalidCertChain(
102                    "author is not certified directly by this user".into(),
103                ))
104            }
105        }
106        VerifyingKey::from_bytes(&self.author.agent_pubkey)
107            .and_then(|vk| vk.verify_strict(&message(cid), &self.sig.0))
108            .map_err(|_| MeshError::BadSignature)
109    }
110}
111
112/// The key of the chain's root agent: the certificate issued by a user.
113fn root_agent(cert: &CertChain) -> &[u8; 32] {
114    match &cert.issuer {
115        Issuer::User(_) => &cert.agent_pubkey,
116        Issuer::Agent { parent, .. } => root_agent(parent),
117    }
118}
119
120#[cfg(test)]
121mod tests {
122    use super::*;
123    use crate::{AgentMetadata, Caveats, UserKey};
124    use ed25519_dalek::Signature;
125
126    fn meta(role: &str) -> AgentMetadata {
127        AgentMetadata {
128            role: role.into(),
129            host: "h".into(),
130            capabilities: vec![],
131            issued_at: "2026-10-15T00:00:00Z".into(),
132            expires_at: None,
133            caveats: Caveats::top(),
134        }
135    }
136
137    fn cid(n: u8) -> ContentId {
138        ContentId::from_canonical_bytes(&[n])
139    }
140
141    /// (server, author certified by server)
142    fn flock(user: &UserKey) -> (AgentKey, AgentKey) {
143        let server = AgentKey::issue(user, meta("server"));
144        let author = server.delegate(meta("author")).unwrap();
145        (server, author)
146    }
147
148    #[test]
149    fn verifies_for_any_reader_holding_the_chain() {
150        let (server, author) = flock(&UserKey::generate());
151        let a = Assertion::sign(&author, &cid(1));
152        // a reader receives only the serialized assertion
153        let wire = serde_json::to_vec(&a).unwrap();
154        let got: Assertion = serde_json::from_slice(&wire).unwrap();
155        got.verify(&cid(1), &server.public_bytes(), 0).unwrap();
156    }
157
158    #[test]
159    fn one_changed_byte_fails() {
160        let (server, author) = flock(&UserKey::generate());
161        let a = Assertion::sign(&author, &cid(1));
162        let root = server.public_bytes();
163        assert!(a.verify(&cid(2), &root, 0).is_err(), "changed CID");
164        let mut bytes = a.sig.0.to_bytes();
165        bytes[0] ^= 1;
166        let mut bad = a.clone();
167        bad.sig = SerdeSig(Signature::from_bytes(&bytes));
168        assert!(bad.verify(&cid(1), &root, 0).is_err(), "changed signature");
169    }
170
171    #[test]
172    fn other_flock_server_fails_even_for_the_same_user() {
173        let user = UserKey::generate();
174        let (_s1, author1) = flock(&user);
175        let (s2, _author2) = flock(&user);
176        let a = Assertion::sign(&author1, &cid(1));
177        assert!(a.verify(&cid(1), &s2.public_bytes(), 0).is_err());
178    }
179
180    #[test]
181    fn domain_tag_separates_from_a_bare_cid_signature() {
182        let (server, author) = flock(&UserKey::generate());
183        let c = cid(1);
184        let a = Assertion {
185            author: author.cert().clone(),
186            sig: SerdeSig(author.sign(&c.to_bytes())),
187        };
188        assert!(a.verify(&c, &server.public_bytes(), 0).is_err());
189    }
190
191    fn seed_of(k: &AgentKey) -> [u8; 32] {
192        k.signing_key_bytes()
193    }
194
195    #[test]
196    fn intermediate_match_with_other_root_agent_is_refused() {
197        // user -> other -> pinned -> author: root agent is `other`.
198        let user = UserKey::generate();
199        let other = AgentKey::issue(&user, meta("other"));
200        let pinned = AgentKey::issue(&user, meta("pinned"));
201        let ch = other.possession_challenge(pinned.public_bytes());
202        let proof = pinned.sign(&ch.signing_bytes());
203        let cert = other
204            .delegate_external(&ch, &proof, meta("pinned"))
205            .unwrap();
206        let pinned_via_other = AgentKey::from_seed_and_cert(&seed_of(&pinned), cert).unwrap();
207        let author = pinned_via_other.delegate(meta("author")).unwrap();
208        let a = Assertion::sign(&author, &cid(1));
209        assert!(a.verify(&cid(1), &pinned.public_bytes(), 0).is_err());
210        a.verify(&cid(1), &other.public_bytes(), 0).unwrap();
211    }
212
213    #[test]
214    fn author_leaf_as_trust_root_is_refused() {
215        let (_server, author) = flock(&UserKey::generate());
216        let a = Assertion::sign(&author, &cid(1));
217        assert!(a.verify(&cid(1), &author.public_bytes(), 0).is_err());
218    }
219
220    #[test]
221    fn bounded_generation_valid_at_n_refused_at_n_plus_1() {
222        let user = UserKey::generate();
223        let mut m = meta("server");
224        m.caveats.valid_for_generation = crate::Scope::only([5u64]);
225        let server = AgentKey::issue(&user, m.clone());
226        let author = server.delegate(m).unwrap();
227        let a = Assertion::sign(&author, &cid(1));
228        a.verify(&cid(1), &server.public_bytes(), 5).unwrap();
229        assert!(a.verify(&cid(1), &server.public_bytes(), 6).is_err());
230        assert!(a.verify(&cid(1), &server.public_bytes(), 4).is_err());
231    }
232
233    #[test]
234    fn verify_user_accepts_a_device_of_that_user_only() {
235        let user = UserKey::generate();
236        let dev = AgentKey::issue(&user, meta("operator"));
237        let c = cid(7);
238        let a = Assertion::sign(&dev, &c);
239        a.verify_user(&c, &user.public(), 0).unwrap();
240        // another user, another cid, and a delegated (two-link) key all fail
241        assert!(a.verify_user(&c, &UserKey::generate().public(), 0).is_err());
242        assert!(a.verify_user(&cid(8), &user.public(), 0).is_err());
243        let child = dev.delegate(meta("x")).unwrap();
244        assert!(Assertion::sign(&child, &c)
245            .verify_user(&c, &user.public(), 0)
246            .is_err());
247    }
248}