Skip to main content

lit/identity/
ucan.rs

1//! UCAN (User Controlled Authorization Networks) capability delegation
2//!
3//! Enables agents to delegate a subset of their permissions to other agents.
4//! Example: "You can push to branch 'feature' for 1 hour."
5//!
6//! Based on UCAN spec: https://ucan.xyz
7
8use crate::errors::LitError;
9use serde::{Deserialize, Serialize};
10use sha3::{Digest, Sha3_256};
11use std::fs;
12use std::path::Path;
13
14/// A capability that can be delegated via UCAN
15#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
16pub struct Capability {
17    /// Resource the capability applies to (e.g., "repo:*", "branch:main", "file:src/*")
18    pub resource: String,
19    /// Allowed action (e.g., "push", "commit", "merge", "read", "admin")
20    pub action: String,
21    /// Optional constraints (e.g., max commits, read-only paths)
22    #[serde(skip_serializing_if = "Option::is_none")]
23    pub caveats: Option<serde_json::Value>,
24}
25
26impl std::fmt::Display for Capability {
27    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
28        write!(f, "{}:{}", self.resource, self.action)
29    }
30}
31
32/// A UCAN token for capability delegation
33#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct UcanToken {
35    /// Token version
36    pub version: String,
37    /// Issuer DID (the delegator)
38    pub issuer: String,
39    /// Audience DID (the delegatee)
40    pub audience: String,
41    /// Capabilities being delegated
42    pub capabilities: Vec<Capability>,
43    /// Expiration timestamp (Unix epoch seconds)
44    pub expiration: i64,
45    /// Not-before timestamp
46    #[serde(skip_serializing_if = "Option::is_none")]
47    pub not_before: Option<i64>,
48    /// Nonce for uniqueness
49    pub nonce: String,
50    /// Proof chain — parent UCAN CIDs that authorize this delegation
51    #[serde(default)]
52    pub proof: Vec<String>,
53    /// Signature of the token payload by the issuer
54    pub signature: String,
55}
56
57impl UcanToken {
58    /// Create a new UCAN token (unsigned)
59    pub fn new(
60        issuer: String,
61        audience: String,
62        capabilities: Vec<Capability>,
63        duration_secs: i64,
64    ) -> Self {
65        let now = chrono::Utc::now().timestamp();
66        let nonce = format!("{:016x}", now ^ std::process::id() as i64);
67
68        UcanToken {
69            version: "0.10.0".to_string(),
70            issuer,
71            audience,
72            capabilities,
73            expiration: now + duration_secs,
74            not_before: Some(now),
75            nonce,
76            proof: Vec::new(),
77            signature: String::new(),
78        }
79    }
80
81    /// Sign the token with the issuer's private key material
82    pub fn sign(&mut self, private_key_hex: &str) -> Result<(), LitError> {
83        let payload = self.payload_bytes()?;
84        let private_bytes = hex::decode(private_key_hex)
85            .map_err(|e| LitError::general(format!("Invalid key: {}", e)))?;
86
87        let mut hasher = Sha3_256::new();
88        hasher.update(&private_bytes);
89        hasher.update(&payload);
90        let sig = hasher.finalize();
91        self.signature = hex::encode(sig);
92        Ok(())
93    }
94
95    /// Verify the token's signature
96    pub fn verify(&self, issuer_public_key_hex: &str) -> Result<bool, LitError> {
97        let payload = self.payload_bytes()?;
98        let public_bytes = hex::decode(issuer_public_key_hex)
99            .map_err(|e| LitError::general(format!("Invalid key: {}", e)))?;
100
101        let mut hasher = Sha3_256::new();
102        hasher.update(&public_bytes);
103        hasher.update(&payload);
104        let expected = hasher.finalize();
105
106        let sig_bytes = hex::decode(&self.signature)
107            .map_err(|e| LitError::general(format!("Invalid signature: {}", e)))?;
108
109        Ok(subtle::ConstantTimeEq::ct_eq(sig_bytes.as_slice(), expected.as_slice()).into())
110    }
111
112    /// Check if the token is currently valid (not expired, not before)
113    pub fn is_valid(&self) -> bool {
114        let now = chrono::Utc::now().timestamp();
115        if now > self.expiration {
116            return false;
117        }
118        if let Some(nb) = self.not_before {
119            if now < nb {
120                return false;
121            }
122        }
123        true
124    }
125
126    /// Check if this token grants a specific capability
127    pub fn has_capability(&self, resource: &str, action: &str) -> bool {
128        self.capabilities.iter().any(|cap| {
129            let resource_match = cap.resource == "*"
130                || cap.resource == resource
131                || cap
132                    .resource
133                    .strip_suffix('*')
134                    .map(|prefix| resource.starts_with(prefix))
135                    .unwrap_or(false)
136                || resource.starts_with(&cap.resource);
137            resource_match && (cap.action == "*" || cap.action == action)
138        })
139    }
140
141    /// Get the content-addressable ID (hash) of this token
142    pub fn cid(&self) -> Result<String, LitError> {
143        let bytes = self.payload_bytes()?;
144        let hash = Sha3_256::digest(&bytes);
145        Ok(hex::encode(hash))
146    }
147
148    /// Delegate a subset of capabilities to another agent (create a child UCAN)
149    pub fn delegate(
150        &self,
151        new_audience: String,
152        capabilities: Vec<Capability>,
153        duration_secs: i64,
154    ) -> Result<UcanToken, LitError> {
155        // Verify all delegated capabilities are a subset of parent
156        for cap in &capabilities {
157            if !self.has_capability(&cap.resource, &cap.action) {
158                return Err(LitError::general(format!(
159                    "Cannot delegate capability '{}:{}' — not held by parent token",
160                    cap.resource, cap.action
161                )));
162            }
163        }
164
165        let parent_cid = self.cid()?;
166        let mut child = UcanToken::new(
167            self.audience.clone(), // Delegatee becomes the new issuer
168            new_audience,
169            capabilities,
170            duration_secs,
171        );
172        child.proof.push(parent_cid);
173
174        // Inherit parent's proof chain
175        for p in &self.proof {
176            child.proof.push(p.clone());
177        }
178
179        Ok(child)
180    }
181
182    fn payload_bytes(&self) -> Result<Vec<u8>, LitError> {
183        // Serialize everything except signature for hashing
184        let payload = serde_json::json!({
185            "version": self.version,
186            "issuer": self.issuer,
187            "audience": self.audience,
188            "capabilities": self.capabilities,
189            "expiration": self.expiration,
190            "not_before": self.not_before,
191            "nonce": self.nonce,
192            "proof": self.proof,
193        });
194        serde_json::to_vec(&payload)
195            .map_err(|e| LitError::general(format!("Failed to serialize UCAN: {}", e)))
196    }
197}
198
199/// Store for managing UCAN tokens
200pub fn ucan_dir(repo_root: &Path) -> std::path::PathBuf {
201    repo_root.join(".lit").join("ucan")
202}
203
204/// Save a UCAN token
205pub fn save_token(repo_root: &Path, token: &UcanToken) -> Result<String, LitError> {
206    let dir = ucan_dir(repo_root);
207    fs::create_dir_all(&dir)
208        .map_err(|e| LitError::io(format!("Failed to create UCAN dir: {}", e)))?;
209
210    let cid = token.cid()?;
211    let path = dir.join(format!("{}.json", &cid[..16]));
212    let json = serde_json::to_string_pretty(token)
213        .map_err(|e| LitError::general(format!("Failed to serialize token: {}", e)))?;
214    fs::write(&path, json).map_err(|e| LitError::io(format!("Failed to write token: {}", e)))?;
215    Ok(cid)
216}
217
218/// Load all UCAN tokens for a given audience
219pub fn load_tokens_for(repo_root: &Path, audience_did: &str) -> Result<Vec<UcanToken>, LitError> {
220    let dir = ucan_dir(repo_root);
221    if !dir.exists() {
222        return Ok(Vec::new());
223    }
224
225    let mut tokens = Vec::new();
226    for entry in fs::read_dir(&dir).map_err(|e| LitError::io(format!("IO error: {}", e)))? {
227        let entry = entry.map_err(|e| LitError::io(format!("IO error: {}", e)))?;
228        if entry.path().extension().is_some_and(|e| e == "json") {
229            if let Ok(json) = fs::read_to_string(entry.path()) {
230                if let Ok(token) = serde_json::from_str::<UcanToken>(&json) {
231                    if token.audience == audience_did && token.is_valid() {
232                        tokens.push(token);
233                    }
234                }
235            }
236        }
237    }
238    Ok(tokens)
239}
240
241/// Revoke a UCAN token by CID
242pub fn revoke_token(repo_root: &Path, cid_prefix: &str) -> Result<(), LitError> {
243    let dir = ucan_dir(repo_root);
244    let path = dir.join(format!("{}.json", cid_prefix));
245    if path.exists() {
246        fs::remove_file(&path)
247            .map_err(|e| LitError::io(format!("Failed to revoke token: {}", e)))?;
248        Ok(())
249    } else {
250        Err(LitError::general(format!(
251            "Token not found: {}",
252            cid_prefix
253        )))
254    }
255}
256
257#[cfg(test)]
258mod tests {
259    use super::*;
260
261    #[test]
262    fn test_ucan_creation() {
263        let token = UcanToken::new(
264            "did:lit:issuer123".to_string(),
265            "did:lit:audience456".to_string(),
266            vec![Capability {
267                resource: "branch:main".to_string(),
268                action: "push".to_string(),
269                caveats: None,
270            }],
271            3600,
272        );
273        assert!(token.is_valid());
274        assert!(token.has_capability("branch:main", "push"));
275        assert!(!token.has_capability("branch:main", "delete"));
276    }
277
278    #[test]
279    fn test_ucan_expiration() {
280        let mut token = UcanToken::new(
281            "did:lit:a".to_string(),
282            "did:lit:b".to_string(),
283            vec![],
284            -1, // Already expired
285        );
286        token.not_before = None;
287        assert!(!token.is_valid());
288    }
289
290    #[test]
291    fn test_ucan_delegation() {
292        let parent = UcanToken::new(
293            "did:lit:root".to_string(),
294            "did:lit:agent1".to_string(),
295            vec![Capability {
296                resource: "branch:*".to_string(),
297                action: "*".to_string(),
298                caveats: None,
299            }],
300            3600,
301        );
302
303        let child = parent
304            .delegate(
305                "did:lit:agent2".to_string(),
306                vec![Capability {
307                    resource: "branch:feature".to_string(),
308                    action: "push".to_string(),
309                    caveats: None,
310                }],
311                1800,
312            )
313            .unwrap();
314
315        assert_eq!(child.issuer, "did:lit:agent1");
316        assert_eq!(child.audience, "did:lit:agent2");
317        assert!(!child.proof.is_empty());
318    }
319}