Skip to main content

a1/
intent.rs

1use std::collections::BTreeMap;
2
3use crate::crypto::{hasher_intent_leaf, hasher_subscope, merkle_node};
4use crate::error::A1Error;
5
6/// A 32-byte commitment to a single authorized action.
7pub type IntentHash = [u8; 32];
8
9/// Maximum allowed length in bytes for an intent action string.
10pub const MAX_ACTION_LEN: usize = 256;
11/// Maximum allowed length in bytes for an intent parameter key.
12pub const MAX_PARAM_KEY_LEN: usize = 128;
13/// Maximum allowed length in bytes for an intent parameter value.
14pub const MAX_PARAM_VALUE_LEN: usize = 4096;
15/// Maximum allowed number of parameters per intent.
16pub const MAX_INTENT_PARAMS: usize = 64;
17
18// ── Structured Intent ─────────────────────────────────────────────────────────
19
20/// A human-readable action with named, canonically-ordered parameters.
21///
22/// Parameters are sorted by key before hashing, so two `Intent` values
23/// with identical fields but different insertion order produce the same hash.
24/// This makes intent construction order-independent and audit logs readable.
25///
26/// # Examples
27///
28/// ```rust
29/// use a1::Intent;
30///
31/// let intent = Intent::new("trade.equity").unwrap()
32///     .param("symbol", "AAPL")
33///     .param("side", "buy")
34///     .param("limit_usd", "182.50")
35///     .param("qty", "100");
36///
37/// let h = intent.hash();
38/// assert_ne!(h, [0u8; 32]);
39/// ```
40#[derive(Debug, Clone, PartialEq, Eq)]
41#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
42pub struct Intent {
43    /// Action identifier (e.g. `"trade.equity"`, `"query.portfolio"`).
44    pub action: String,
45    /// Named parameters, sorted by key for canonical serialization.
46    pub params: BTreeMap<String, String>,
47}
48
49impl Intent {
50    /// Create a new intent. Returns `A1Error::WireFormatError` if the action string
51    /// is empty or exceeds `MAX_ACTION_LEN`.
52    ///
53    /// # Examples
54    ///
55    /// ```rust
56    /// use a1::Intent;
57    ///
58    /// let intent = Intent::new("trade.equity").unwrap()
59    ///     .param("symbol", "AAPL")
60    ///     .param("side", "buy");
61    ///
62    /// assert!(Intent::new("").is_err());
63    /// ```
64    pub fn new(action: impl Into<String>) -> Result<Self, A1Error> {
65        let action_str = action.into();
66        if action_str.is_empty() {
67            return Err(A1Error::WireFormatError(
68                "Intent action cannot be empty".into(),
69            ));
70        }
71        if action_str.len() > MAX_ACTION_LEN {
72            return Err(A1Error::WireFormatError(format!(
73                "Intent action exceeds maximum length of {MAX_ACTION_LEN}"
74            )));
75        }
76        Ok(Self {
77            action: action_str,
78            params: BTreeMap::new(),
79        })
80    }
81
82    /// Alias for `new`. Prefer `new` in new code; retained for API symmetry with `try_param`.
83    #[inline]
84    pub fn try_new(action: impl Into<String>) -> Result<Self, A1Error> {
85        Self::new(action)
86    }
87
88    /// Attach a named parameter. Replaces any existing value for the same key.
89    /// Panics on debug if limits are exceeded.
90    /// Use `try_param` for validation-critical paths.
91    pub fn param(self, key: impl Into<String>, value: impl Into<String>) -> Self {
92        self.try_param(key, value)
93            .expect("invalid intent parameter")
94    }
95
96    /// Attach a named parameter. Replaces any existing value for the same key.
97    /// Returns `A1Error::WireFormatError` if limits are exceeded.
98    ///
99    /// Keys and values are normalized to lowercase and trimmed to ensure
100    /// deterministic hashing regardless of how the caller constructs them.
101    pub fn try_param(
102        mut self,
103        key: impl Into<String>,
104        value: impl Into<String>,
105    ) -> Result<Self, A1Error> {
106        if self.params.len() >= MAX_INTENT_PARAMS {
107            return Err(A1Error::WireFormatError(format!(
108                "Intent exceeds maximum parameter count of {}",
109                MAX_INTENT_PARAMS
110            )));
111        }
112        let normalized_key = key.into().trim().to_lowercase();
113        let normalized_value = value.into().trim().to_lowercase();
114        if normalized_key.len() > MAX_PARAM_KEY_LEN {
115            return Err(A1Error::WireFormatError(format!(
116                "Intent parameter key exceeds maximum length of {}",
117                MAX_PARAM_KEY_LEN
118            )));
119        }
120        if normalized_value.len() > MAX_PARAM_VALUE_LEN {
121            return Err(A1Error::WireFormatError(format!(
122                "Intent parameter value exceeds maximum length of {}",
123                MAX_PARAM_VALUE_LEN
124            )));
125        }
126        self.params.insert(normalized_key, normalized_value);
127        Ok(self)
128    }
129
130    /// Compute the domain-separated hash of this intent.
131    ///
132    /// Uses a prefix-free canonical encoding: each field is length-prefixed
133    /// before its content, preventing length-extension and collision attacks.
134    pub fn hash(&self) -> IntentHash {
135        let mut h = hasher_intent_leaf(crate::cert::CERT_VERSION);
136        h.update(b"a1::dyolo::intent::v2.8.0");
137        h.update(&(self.action.len() as u64).to_le_bytes());
138        h.update(self.action.as_bytes());
139        h.update(&(self.params.len() as u64).to_le_bytes());
140        for (k, v) in self.params.iter() {
141            h.update(&(k.len() as u64).to_le_bytes());
142            h.update(k.as_bytes());
143            h.update(&(v.len() as u64).to_le_bytes());
144            h.update(v.as_bytes());
145        }
146        h.finalize().into()
147    }
148}
149
150impl std::fmt::Display for Intent {
151    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
152        write!(f, "{}", self.action)?;
153        if !self.params.is_empty() {
154            write!(f, "[")?;
155            let mut iter = self.params.iter().peekable();
156            while let Some((k, v)) = iter.next() {
157                write!(f, "{k}={v}")?;
158                if iter.peek().is_some() {
159                    write!(f, ",")?;
160                }
161            }
162            write!(f, "]")?;
163        }
164        Ok(())
165    }
166}
167
168// ── Low-level hashing ─────────────────────────────────────────────────────────
169
170/// Hash a raw action identifier and opaque parameter bytes into a
171/// domain-separated leaf hash.
172///
173/// The `params` argument is treated as a single opaque byte slice and encoded
174/// with a length prefix before hashing. This encoding differs from
175/// [`Intent::hash`], which encodes a `BTreeMap` of named key-value pairs with
176/// individual per-field length prefixes. The two functions are **not**
177/// interchangeable; use this only when the parameter payload is already
178/// serialized by the caller and no structured field access is needed.
179#[deprecated(
180    since = "2.0.0",
181    note = "Use `Intent::new` and `Intent::hash` to avoid serialization mismatches. This function will be removed in v3.0."
182)]
183pub fn intent_hash(action: &str, params: &[u8]) -> IntentHash {
184    let mut h = hasher_intent_leaf(crate::cert::CERT_VERSION);
185    h.update(&(action.len() as u64).to_le_bytes());
186    h.update(action.as_bytes());
187    h.update(&(params.len() as u64).to_le_bytes());
188    h.update(params);
189    h.finalize().into()
190}
191
192// ── Merkle Proof ──────────────────────────────────────────────────────────────
193
194/// A Merkle path proving that one leaf belongs to a tree.
195#[derive(Clone, Debug, Default, PartialEq)]
196#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
197pub struct MerkleProof {
198    pub siblings: Vec<SiblingNode>,
199}
200
201/// One node along a Merkle path.
202#[derive(Clone, Debug, PartialEq)]
203#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
204pub struct SiblingNode {
205    pub hash: IntentHash,
206    /// When `true`, this sibling is the left child; the current node is on the right.
207    pub is_left: bool,
208}
209
210impl MerkleProof {
211    /// Recompute the root from `leaf` along this proof path and return whether
212    /// it equals `expected_root`.
213    pub fn verify(&self, leaf: &IntentHash, expected_root: &IntentHash) -> bool {
214        let mut current = *leaf;
215        for node in &self.siblings {
216            current = if node.is_left {
217                merkle_node(&node.hash, &current)
218            } else {
219                merkle_node(&current, &node.hash)
220            };
221        }
222        use subtle::ConstantTimeEq;
223        current.ct_eq(expected_root).into()
224    }
225}
226
227// ── Intent Tree ───────────────────────────────────────────────────────────────
228
229/// A Merkle tree over a set of authorized intent hashes.
230///
231/// The root commits to the full intent set without revealing its members.
232/// Leaves are sorted and deduplicated, so the root is deterministic regardless
233/// of insertion order.
234pub struct IntentTree {
235    leaves: Vec<IntentHash>,
236    layers: Vec<Vec<IntentHash>>,
237}
238
239impl IntentTree {
240    /// Build a tree from a set of intent hashes.
241    /// Returns [`A1Error::EmptyTree`] if `intents` is empty.
242    pub fn build(mut intents: Vec<IntentHash>) -> Result<Self, A1Error> {
243        if intents.is_empty() {
244            return Err(A1Error::EmptyTree);
245        }
246        intents.sort_unstable();
247        intents.dedup();
248
249        let depth = (usize::BITS - intents.len().leading_zeros()) as usize;
250        let mut layers: Vec<Vec<IntentHash>> = Vec::with_capacity(depth);
251        layers.push(intents.clone());
252
253        let mut current = intents;
254        while current.len() > 1 {
255            let next_len = current.len().div_ceil(2);
256            let mut next = Vec::with_capacity(next_len);
257            for chunk in current.chunks(2) {
258                if chunk.len() == 2 {
259                    next.push(merkle_node(&chunk[0], &chunk[1]));
260                } else {
261                    next.push(chunk[0]);
262                }
263            }
264            layers.push(next.clone());
265            current = next;
266        }
267
268        let leaves = layers.first().expect("layers is never empty").clone();
269        Ok(Self { leaves, layers })
270    }
271
272    /// The Merkle root — the canonical commitment to this intent set.
273    pub fn root(&self) -> IntentHash {
274        self.layers.last().unwrap()[0]
275    }
276
277    /// Generate an inclusion proof for `intent`.
278    /// Returns [`A1Error::IntentNotFound`] if the intent is not in this tree.
279    pub fn prove(&self, intent: &IntentHash) -> Result<MerkleProof, A1Error> {
280        let mut pos = self
281            .leaves
282            .binary_search(intent)
283            .map_err(|_| A1Error::IntentNotFound)?;
284
285        let mut siblings = Vec::new();
286        for layer in self.layers.iter().take(self.layers.len() - 1) {
287            let sibling_pos = if pos.is_multiple_of(2) {
288                pos + 1
289            } else {
290                pos - 1
291            };
292            if sibling_pos < layer.len() {
293                siblings.push(SiblingNode {
294                    hash: layer[sibling_pos],
295                    is_left: !pos.is_multiple_of(2),
296                });
297            }
298            pos /= 2;
299        }
300
301        Ok(MerkleProof { siblings })
302    }
303
304    pub fn contains(&self, intent: &IntentHash) -> bool {
305        self.leaves.binary_search(intent).is_ok()
306    }
307
308    pub fn leaf_count(&self) -> usize {
309        self.leaves.len()
310    }
311}
312
313// ── Sub-scope Proof ───────────────────────────────────────────────────────────
314
315/// Cryptographic evidence that a delegated scope is a strict subset of the
316/// delegator's authorized intent set.
317///
318/// Each entry in `subset_intents` has a corresponding Merkle proof against
319/// the parent scope root. After verification, the subset is formed into its
320/// own Merkle tree whose root becomes the child scope root.
321///
322/// An empty `SubScopeProof` is a full-scope pass-through: the child receives
323/// the same scope root as the parent. This is explicit in the API — callers
324/// must consciously choose between [`SubScopeProof::full_passthrough`] and
325/// [`SubScopeProof::build`].
326#[derive(Clone, Debug, Default)]
327#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
328pub struct SubScopeProof {
329    pub subset_intents: Vec<IntentHash>,
330    pub proofs: Vec<MerkleProof>,
331}
332
333impl SubScopeProof {
334    /// Full-scope pass-through: the delegated scope equals the parent scope.
335    pub fn full_passthrough() -> Self {
336        Self::default()
337    }
338
339    /// Build a sub-scope proof by proving each intent against `parent_tree`.
340    /// Returns [`A1Error::IntentNotFound`] if any intent is absent from the tree.
341    pub fn build(parent_tree: &IntentTree, intents: &[IntentHash]) -> Result<Self, A1Error> {
342        let proofs = intents
343            .iter()
344            .map(|intent| parent_tree.prove(intent))
345            .collect::<Result<Vec<_>, _>>()?;
346        Ok(Self {
347            subset_intents: intents.to_vec(),
348            proofs,
349        })
350    }
351
352    /// Verify every subset intent against `parent_root`, then return the
353    /// Merkle root of the subset as the new delegated scope root.
354    ///
355    /// Returns [`A1Error::InvalidSubScopeProof`] if any proof fails.
356    pub fn verify_and_derive_root(&self, parent_root: &IntentHash) -> Result<IntentHash, A1Error> {
357        if self.subset_intents.is_empty() {
358            return Ok(*parent_root);
359        }
360        if self.subset_intents.len() != self.proofs.len() {
361            return Err(A1Error::InvalidSubScopeProof);
362        }
363        for (intent, proof) in self.subset_intents.iter().zip(self.proofs.iter()) {
364            if !proof.verify(intent, parent_root) {
365                return Err(A1Error::InvalidSubScopeProof);
366            }
367        }
368        let sub_tree = IntentTree::build(self.subset_intents.clone())?;
369        Ok(sub_tree.root())
370    }
371
372    /// A deterministic commitment to the full proof structure.
373    ///
374    /// Included in every certificate signature so no one can substitute
375    /// a different proof on an existing certificate.
376    pub fn commitment(&self) -> [u8; 32] {
377        let mut h = hasher_subscope(crate::cert::CERT_VERSION);
378        h.update(b"a1::dyolo::subscope::v2.8.0");
379        h.update(&(self.subset_intents.len() as u64).to_le_bytes());
380        for intent in &self.subset_intents {
381            h.update(intent);
382        }
383        h.update(&(self.proofs.len() as u64).to_le_bytes());
384        for proof in &self.proofs {
385            h.update(&(proof.siblings.len() as u64).to_le_bytes());
386            for node in &proof.siblings {
387                h.update(&node.hash);
388                h.update(&[node.is_left as u8]);
389            }
390        }
391        h.finalize().into()
392    }
393}
394
395// ── Tests ─────────────────────────────────────────────────────────────────────
396
397#[cfg(test)]
398mod tests {
399    use super::*;
400
401    #[allow(deprecated)]
402    fn sample_intents() -> Vec<IntentHash> {
403        (0..8u8)
404            .map(|i| intent_hash(&format!("action_{i}"), &[i]))
405            .collect()
406    }
407
408    #[test]
409    fn tree_root_is_deterministic() {
410        let a = IntentTree::build(sample_intents()).unwrap();
411        let mut reversed = sample_intents();
412        reversed.reverse();
413        let b = IntentTree::build(reversed).unwrap();
414        assert_eq!(a.root(), b.root());
415    }
416
417    #[test]
418    fn proofs_verify_for_all_leaves() {
419        let intents = sample_intents();
420        let tree = IntentTree::build(intents.clone()).unwrap();
421        let root = tree.root();
422        for intent in &intents {
423            let proof = tree.prove(intent).unwrap();
424            assert!(proof.verify(intent, &root));
425        }
426    }
427
428    #[test]
429    #[allow(deprecated)]
430    fn unknown_intent_proof_fails() {
431        let tree = IntentTree::build(sample_intents()).unwrap();
432        let unknown = intent_hash("unknown", b"");
433        assert_eq!(tree.prove(&unknown), Err(A1Error::IntentNotFound));
434    }
435
436    #[test]
437    fn sub_scope_derives_correct_root() {
438        let intents = sample_intents();
439        let tree = IntentTree::build(intents.clone()).unwrap();
440        let subset = &intents[..3];
441
442        let proof = SubScopeProof::build(&tree, subset).unwrap();
443        let derived = proof.verify_and_derive_root(&tree.root()).unwrap();
444
445        let expected = IntentTree::build(subset.to_vec()).unwrap().root();
446        assert_eq!(derived, expected);
447    }
448
449    #[test]
450    fn full_passthrough_returns_parent_root() {
451        let tree = IntentTree::build(sample_intents()).unwrap();
452        let root = tree.root();
453        let derived = SubScopeProof::full_passthrough()
454            .verify_and_derive_root(&root)
455            .unwrap();
456        assert_eq!(derived, root);
457    }
458
459    #[test]
460    fn intent_struct_hash_is_order_independent() {
461        let a = Intent::new("trade")
462            .unwrap()
463            .param("symbol", "AAPL")
464            .param("qty", "100");
465        let b = Intent::new("trade")
466            .unwrap()
467            .param("qty", "100")
468            .param("symbol", "AAPL");
469        assert_eq!(a.hash(), b.hash());
470    }
471
472    #[test]
473    fn intent_display() {
474        let s = Intent::new("trade.equity")
475            .unwrap()
476            .param("symbol", "AAPL")
477            .to_string();
478        assert!(s.contains("trade.equity"));
479        assert!(s.contains("symbol=aapl"));
480    }
481}