Skip to main content

a1/
cert.rs

1use ed25519_dalek::{Signature, Verifier, VerifyingKey};
2
3use crate::chain::Clock;
4use crate::crypto::{hasher_cert_fp, hasher_cert_sig};
5use crate::error::A1Error;
6use crate::identity::Signer;
7use crate::intent::IntentHash;
8use crate::registry::fresh_nonce;
9use crate::SubScopeProof;
10
11#[cfg(feature = "wire")]
12use crate::cert_extensions::CertExtensions;
13
14/// Wire format version for `DelegationCert`.
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16#[repr(u8)]
17pub enum CertVersion {
18    V1 = 1,
19}
20
21impl CertVersion {
22    pub fn as_u8(self) -> u8 {
23        self as u8
24    }
25}
26
27pub const CERT_VERSION: u8 = 1;
28
29#[derive(Clone, Debug)]
30#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
31pub struct DelegationCert {
32    pub version: u8,
33    pub delegator_pk: VerifyingKey,
34    pub delegate_pk: VerifyingKey,
35    pub scope_root: IntentHash,
36    pub scope_proof: SubScopeProof,
37    pub nonce: [u8; 16],
38    pub issued_at: u64,
39    pub expiration_unix: u64,
40    pub max_depth: u8,
41    #[cfg(not(feature = "wire"))]
42    #[cfg_attr(feature = "serde", serde(skip))]
43    pub extensions_hash: Option<[u8; 32]>,
44    #[cfg(feature = "wire")]
45    #[serde(default)]
46    pub extensions: CertExtensions,
47    pub signature: Signature,
48}
49
50impl DelegationCert {
51    #[allow(clippy::too_many_arguments)]
52    #[inline(always)]
53    pub fn signable_bytes(
54        version: u8,
55        delegator_pk: &VerifyingKey,
56        delegate_pk: &VerifyingKey,
57        scope_root: &IntentHash,
58        scope_proof: &SubScopeProof,
59        nonce: &[u8; 16],
60        issued_at: u64,
61        expiration_unix: u64,
62        max_depth: u8,
63        ext_commitment: &[u8; 32],
64    ) -> Vec<u8> {
65        // We use hasher_cert_sig to get the explicit version byte in the domain derivation.
66        let mut h = hasher_cert_sig(version);
67        h.update(b"a1::dyolo::cert::sig::v2.8.0");
68        h.update(delegator_pk.as_bytes());
69        h.update(delegate_pk.as_bytes());
70        h.update(scope_root);
71        h.update(&scope_proof.commitment());
72        h.update(nonce);
73        h.update(&issued_at.to_be_bytes());
74        h.update(&expiration_unix.to_be_bytes());
75        h.update(&[max_depth]);
76        h.update(ext_commitment);
77        // We extract the finalized bytes directly as the signable payload to keep the Ed25519
78        // signature robust against long message attacks while providing a fixed size.
79        h.finalize().as_bytes().to_vec()
80    }
81
82    #[allow(clippy::too_many_arguments)]
83    pub(crate) fn issue(
84        delegator: &dyn Signer,
85        delegate_pk: VerifyingKey,
86        scope_root: IntentHash,
87        scope_proof: SubScopeProof,
88        nonce: [u8; 16],
89        issued_at: u64,
90        expiration_unix: u64,
91        max_depth: u8,
92        #[cfg(feature = "wire")] extensions: CertExtensions,
93        #[cfg(not(feature = "wire"))] extensions_hash: Option<[u8; 32]>,
94    ) -> Self {
95        let delegator_pk = delegator.verifying_key();
96
97        #[cfg(feature = "wire")]
98        let ext_commit = extensions.commitment();
99        #[cfg(not(feature = "wire"))]
100        let ext_commit = extensions_hash.unwrap_or_else(|| {
101            let mut h = crate::crypto::derive_key("a1::dyolo::cert::ext::v2.8.0", CERT_VERSION);
102            h.update(&0u64.to_le_bytes());
103            h.finalize().into()
104        });
105
106        let msg = Self::signable_bytes(
107            CERT_VERSION,
108            &delegator_pk,
109            &delegate_pk,
110            &scope_root,
111            &scope_proof,
112            &nonce,
113            issued_at,
114            expiration_unix,
115            max_depth,
116            &ext_commit,
117        );
118        Self {
119            version: CERT_VERSION,
120            delegator_pk,
121            delegate_pk,
122            scope_root,
123            scope_proof,
124            nonce,
125            issued_at,
126            expiration_unix,
127            max_depth,
128            #[cfg(not(feature = "wire"))]
129            extensions_hash,
130            #[cfg(feature = "wire")]
131            extensions,
132            signature: delegator.sign_message(&msg),
133        }
134    }
135
136    pub fn verify_signature(&self) -> bool {
137        #[cfg(feature = "wire")]
138        let ext_commit = self.extensions.commitment();
139        #[cfg(not(feature = "wire"))]
140        let ext_commit = self.extensions_hash.unwrap_or_else(|| {
141            let mut h = crate::crypto::derive_key("a1::dyolo::cert::ext::v2.8.0", self.version);
142            h.update(&0u64.to_le_bytes());
143            h.finalize().into()
144        });
145
146        let msg = Self::signable_bytes(
147            self.version,
148            &self.delegator_pk,
149            &self.delegate_pk,
150            &self.scope_root,
151            &self.scope_proof,
152            &self.nonce,
153            self.issued_at,
154            self.expiration_unix,
155            self.max_depth,
156            &ext_commit,
157        );
158        self.delegator_pk.verify(&msg, &self.signature).is_ok()
159    }
160
161    #[must_use]
162    pub fn fingerprint(&self) -> [u8; 32] {
163        let mut h = hasher_cert_fp(self.version);
164        h.update(b"a1::dyolo::cert::fp::v2.8.0");
165        h.update(&self.signature.to_bytes());
166        h.finalize().into()
167    }
168
169    pub fn fingerprint_hex(&self) -> String {
170        hex::encode(self.fingerprint())
171    }
172
173    pub fn ttl_secs(&self) -> u64 {
174        self.expiration_unix.saturating_sub(self.issued_at)
175    }
176}
177
178// ── CertBuilder ───────────────────────────────────────────────────────────────
179
180pub struct CertBuilder {
181    delegate_pk: VerifyingKey,
182    scope_root: IntentHash,
183    scope_proof: SubScopeProof,
184    nonce: [u8; 16],
185    issued_at: u64,
186    expiration_unix: u64,
187    max_depth: u8,
188    #[cfg(feature = "wire")]
189    extensions: CertExtensions,
190    #[cfg(not(feature = "wire"))]
191    extensions_hash: Option<[u8; 32]>,
192}
193
194impl CertBuilder {
195    pub fn new(
196        delegate_pk: VerifyingKey,
197        scope_root: IntentHash,
198        issued_at: u64,
199        expiration_unix: u64,
200    ) -> Self {
201        Self {
202            delegate_pk,
203            scope_root,
204            scope_proof: SubScopeProof::full_passthrough(),
205            nonce: fresh_nonce(),
206            issued_at,
207            expiration_unix,
208            max_depth: 16,
209            #[cfg(feature = "wire")]
210            extensions: CertExtensions::new(),
211            #[cfg(not(feature = "wire"))]
212            extensions_hash: None,
213        }
214    }
215
216    pub fn scope_proof(mut self, proof: SubScopeProof) -> Self {
217        self.scope_proof = proof;
218        self
219    }
220
221    pub fn nonce(mut self, nonce: [u8; 16]) -> Self {
222        self.nonce = nonce;
223        self
224    }
225
226    pub fn max_depth(mut self, depth: u8) -> Self {
227        self.max_depth = depth;
228        self
229    }
230
231    #[cfg(feature = "wire")]
232    pub fn extensions(mut self, ext: CertExtensions) -> Self {
233        self.extensions = ext;
234        self
235    }
236
237    #[cfg(not(feature = "wire"))]
238    pub fn extensions_hash(mut self, hash: [u8; 32]) -> Self {
239        self.extensions_hash = Some(hash);
240        self
241    }
242
243    pub fn build(self, delegator: &dyn Signer) -> Result<DelegationCert, A1Error> {
244        if self.issued_at >= self.expiration_unix {
245            return Err(A1Error::WireFormatError(format!(
246                "issued_at ({}) must be strictly less than expiration_unix ({})",
247                self.issued_at, self.expiration_unix
248            )));
249        }
250        Ok(DelegationCert::issue(
251            delegator,
252            self.delegate_pk,
253            self.scope_root,
254            self.scope_proof,
255            self.nonce,
256            self.issued_at,
257            self.expiration_unix,
258            self.max_depth,
259            #[cfg(feature = "wire")]
260            self.extensions,
261            #[cfg(not(feature = "wire"))]
262            self.extensions_hash,
263        ))
264    }
265
266    pub fn sign(self, delegator: &dyn Signer) -> DelegationCert {
267        self.build(delegator)
268            .expect("invalid certificate configuration: issued_at must be before expiration_unix")
269    }
270
271    #[cfg(feature = "async")]
272    #[cfg_attr(docsrs, doc(cfg(feature = "async")))]
273    pub async fn build_async(
274        self,
275        delegator: &dyn crate::identity::AsyncSigner,
276    ) -> Result<DelegationCert, A1Error> {
277        if self.issued_at >= self.expiration_unix {
278            return Err(A1Error::WireFormatError(format!(
279                "issued_at ({}) must be strictly less than expiration_unix ({})",
280                self.issued_at, self.expiration_unix
281            )));
282        }
283        let delegator_pk = delegator.verifying_key();
284
285        #[cfg(feature = "wire")]
286        let ext_commit = self.extensions.commitment();
287        #[cfg(not(feature = "wire"))]
288        let ext_commit = self.extensions_hash.unwrap_or_else(|| {
289            let mut h = crate::crypto::derive_key("a1::dyolo::cert::ext::v2.8.0", CERT_VERSION);
290            h.update(&0u64.to_le_bytes());
291            h.finalize().into()
292        });
293
294        let msg = DelegationCert::signable_bytes(
295            CERT_VERSION,
296            &delegator_pk,
297            &self.delegate_pk,
298            &self.scope_root,
299            &self.scope_proof,
300            &self.nonce,
301            self.issued_at,
302            self.expiration_unix,
303            self.max_depth,
304            &ext_commit,
305        );
306
307        Ok(DelegationCert {
308            version: CERT_VERSION,
309            delegator_pk,
310            delegate_pk: self.delegate_pk,
311            scope_root: self.scope_root,
312            scope_proof: self.scope_proof,
313            nonce: self.nonce,
314            issued_at: self.issued_at,
315            expiration_unix: self.expiration_unix,
316            max_depth: self.max_depth,
317            #[cfg(not(feature = "wire"))]
318            extensions_hash: self.extensions_hash,
319            #[cfg(feature = "wire")]
320            extensions: self.extensions,
321            signature: delegator.sign_message(&msg).await,
322        })
323    }
324
325    #[cfg(feature = "async")]
326    #[cfg_attr(docsrs, doc(cfg(feature = "async")))]
327    pub async fn sign_async(self, delegator: &dyn crate::identity::AsyncSigner) -> DelegationCert {
328        self.build_async(delegator)
329            .await
330            .expect("invalid certificate configuration: issued_at must be before expiration_unix")
331    }
332}
333
334// ── CertBundle ────────────────────────────────────────────────────────────────
335
336/// A batch of delegation certs issued in a single atomic call.
337///
338/// All certs in a bundle share the same delegator and timestamp but may have
339/// different delegates, scopes, and TTLs. Issuing in a bundle is semantically
340/// equivalent to issuing each cert individually; the bundle is purely a
341/// transport convenience that lets callers issue a full sub-tree of delegations
342/// in one round-trip to the gateway.
343///
344/// # Example
345///
346/// ```rust,ignore
347/// use a1::cert::CertBundle;
348///
349/// let bundle = CertBundle::issue(&human, now, vec![
350///     CertBuilder::new(agent_a.verifying_key(), scope_a, now, now + 3600),
351///     CertBuilder::new(agent_b.verifying_key(), scope_b, now, now + 1800),
352/// ]);
353/// assert_eq!(bundle.len(), 2);
354/// ```
355#[derive(Debug, Clone)]
356#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
357pub struct CertBundle {
358    pub certs: Vec<DelegationCert>,
359    pub issued_at: u64,
360}
361
362impl CertBundle {
363    pub fn issue(delegator: &dyn Signer, issued_at: u64, builders: Vec<CertBuilder>) -> Self {
364        let certs = builders.into_iter().map(|b| b.sign(delegator)).collect();
365        Self { certs, issued_at }
366    }
367
368    pub fn from_certs(certs: Vec<DelegationCert>, clock: &dyn Clock) -> Self {
369        let issued_at = clock.unix_now();
370        Self { certs, issued_at }
371    }
372
373    pub fn len(&self) -> usize {
374        self.certs.len()
375    }
376    pub fn is_empty(&self) -> bool {
377        self.certs.is_empty()
378    }
379
380    pub fn fingerprints(&self) -> Vec<[u8; 32]> {
381        self.certs.iter().map(|c| c.fingerprint()).collect()
382    }
383}