Skip to main content

murk_cli/
grants.rs

1//! Short-lived agent grants: ephemeral, narrowly-scoped read credentials.
2//!
3//! A grant mints an ephemeral age identity, hands it to an agent, and gives it
4//! read access to a fixed set of keys — without ever exposing the operator's own
5//! key. The agent's pubkey is a vault recipient (so it can verify integrity and
6//! decrypt the meta blob) but is excluded from the shared "everyone" layer; its
7//! access is exactly the set of scoped ciphertexts encrypted to it. Grant
8//! metadata (scope, TTL, issuer) lives in the encrypted meta (see
9//! [`crate::types::Meta::grants`]) and is covered by the keyed MAC, so it cannot
10//! be tampered with undetected.
11//!
12//! The TTL is advisory: age keys cannot self-destruct and old `.murk` versions
13//! stay readable in git, so a leaked grant key works until `agent revoke` +
14//! rotate regardless of expiry. The TTL tells you *when* to revoke; `agent ls`
15//! flags grants that are past it.
16
17use chrono::{DateTime, Duration, Utc};
18
19use crate::error::MurkError;
20use crate::types;
21
22/// Validate a grant name: 1–64 chars of `[A-Za-z0-9_-]`.
23pub fn validate_grant_name(name: &str) -> Result<(), MurkError> {
24    if name.is_empty() {
25        return Err(MurkError::Grant("grant name cannot be empty".into()));
26    }
27    if name.len() > 64 {
28        return Err(MurkError::Grant(
29            "grant name too long (max 64 characters)".into(),
30        ));
31    }
32    if !name
33        .chars()
34        .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
35    {
36        return Err(MurkError::Grant(format!(
37            "invalid grant name \"{name}\" — use letters, digits, dashes, underscores"
38        )));
39    }
40    Ok(())
41}
42
43/// Parse a TTL like `90s`, `30m`, `2h`, or `7d` into a [`Duration`]. A bare
44/// number is rejected — the unit must be explicit so `2` is never ambiguous.
45pub fn parse_ttl(s: &str) -> Result<Duration, MurkError> {
46    let s = s.trim();
47    let (num, unit) = s.split_at(
48        s.find(|c: char| !c.is_ascii_digit())
49            .ok_or_else(|| MurkError::Grant(format!("ttl \"{s}\" needs a unit: s, m, h, or d")))?,
50    );
51    let n: i64 = num
52        .parse()
53        .map_err(|_| MurkError::Grant(format!("invalid ttl \"{s}\" — use e.g. 30m, 2h, 7d")))?;
54    if n <= 0 {
55        return Err(MurkError::Grant("ttl must be positive".into()));
56    }
57    let dur = match unit {
58        "s" => Duration::seconds(n),
59        "m" => Duration::minutes(n),
60        "h" => Duration::hours(n),
61        "d" => Duration::days(n),
62        other => {
63            return Err(MurkError::Grant(format!(
64                "unknown ttl unit \"{other}\" — use s, m, h, or d"
65            )));
66        }
67    };
68    Ok(dur)
69}
70
71/// Create an agent grant in the working state. The caller mints the ephemeral
72/// identity, adds `agent_pubkey` to the vault recipients, and registers its
73/// display name *before* calling this. Encrypts a private copy of each scope
74/// key's shared value to the agent and records the grant metadata.
75///
76/// Errors if the name is invalid or already used, the scope is empty, or a scope
77/// key has no shared value the operator can read (e.g. an unknown key, or one
78/// that is group/scoped-only). Returns the recorded [`types::GrantEntry`].
79pub fn create_grant(
80    current: &mut types::Murk,
81    name: &str,
82    agent_pubkey: &str,
83    scope: &[String],
84    issuer_pubkey: &str,
85    issued_at: DateTime<Utc>,
86    ttl: Duration,
87) -> Result<types::GrantEntry, MurkError> {
88    validate_grant_name(name)?;
89    if current.grants.contains_key(name) {
90        return Err(MurkError::Grant(format!("grant already exists: {name}")));
91    }
92    if scope.is_empty() {
93        return Err(MurkError::Grant(
94            "a grant needs at least one key — pass --only KEY".into(),
95        ));
96    }
97
98    // Sort + de-dup so the scope is deterministic (it's MAC-covered) and a key
99    // passed twice doesn't double-encrypt.
100    let mut scope_sorted = scope.to_vec();
101    scope_sorted.sort();
102    scope_sorted.dedup();
103
104    for key in &scope_sorted {
105        let value = current.values.get(key).ok_or_else(|| {
106            MurkError::Grant(format!(
107                "cannot grant {key}: no shared value to grant (unknown key, or it is group/scoped-only)"
108            ))
109        })?;
110        current
111            .private
112            .entry(key.clone())
113            .or_default()
114            .insert(agent_pubkey.to_string(), value.clone());
115    }
116
117    let entry = types::GrantEntry {
118        pubkey: agent_pubkey.to_string(),
119        scope: scope_sorted,
120        issued_at: fmt_ts(issued_at),
121        expires_at: fmt_ts(issued_at + ttl),
122        issuer: issuer_pubkey.to_string(),
123    };
124    current.grants.insert(name.to_string(), entry.clone());
125    Ok(entry)
126}
127
128/// Remove a grant by name, returning its metadata so the caller can revoke the
129/// agent recipient (which clears its private entries) and rotate the scope.
130pub fn remove_grant(current: &mut types::Murk, name: &str) -> Result<types::GrantEntry, MurkError> {
131    current
132        .grants
133        .remove(name)
134        .ok_or_else(|| MurkError::Grant(format!("grant not found: {name}")))
135}
136
137/// Format a timestamp the same way as the rest of the vault (ISO-8601 UTC,
138/// second precision).
139fn fmt_ts(dt: DateTime<Utc>) -> String {
140    dt.format("%Y-%m-%dT%H:%M:%SZ").to_string()
141}
142
143#[cfg(test)]
144mod tests {
145    use super::*;
146    use zeroize::Zeroizing;
147
148    fn murk_with(keys: &[(&str, &str)]) -> types::Murk {
149        let mut m = types::Murk::default();
150        for (k, v) in keys {
151            m.values
152                .insert((*k).to_string(), Zeroizing::new((*v).to_string()));
153        }
154        m
155    }
156
157    #[test]
158    fn parse_ttl_units() {
159        assert_eq!(parse_ttl("90s").unwrap(), Duration::seconds(90));
160        assert_eq!(parse_ttl("30m").unwrap(), Duration::minutes(30));
161        assert_eq!(parse_ttl("2h").unwrap(), Duration::hours(2));
162        assert_eq!(parse_ttl("7d").unwrap(), Duration::days(7));
163    }
164
165    #[test]
166    fn parse_ttl_rejects_bad_input() {
167        assert!(parse_ttl("2").is_err()); // no unit
168        assert!(parse_ttl("0h").is_err()); // not positive
169        assert!(parse_ttl("-1h").is_err()); // sign is not a digit
170        assert!(parse_ttl("2y").is_err()); // unknown unit
171        assert!(parse_ttl("abc").is_err());
172    }
173
174    #[test]
175    fn validate_grant_name_rules() {
176        assert!(validate_grant_name("codex-debug").is_ok());
177        assert!(validate_grant_name("").is_err());
178        assert!(validate_grant_name("has space").is_err());
179        assert!(validate_grant_name(&"x".repeat(65)).is_err());
180    }
181
182    #[test]
183    fn create_grant_encrypts_scope_and_records_metadata() {
184        let mut current = murk_with(&[("STRIPE_KEY", "sk_live_1"), ("OTHER", "v")]);
185        let issued = DateTime::parse_from_rfc3339("2026-06-16T00:00:00Z")
186            .unwrap()
187            .with_timezone(&Utc);
188        let entry = create_grant(
189            &mut current,
190            "codex",
191            "age1agent",
192            &["STRIPE_KEY".into()],
193            "age1owner",
194            issued,
195            Duration::hours(2),
196        )
197        .unwrap();
198
199        assert_eq!(entry.pubkey, "age1agent");
200        assert_eq!(entry.scope, vec!["STRIPE_KEY".to_string()]);
201        assert_eq!(entry.issued_at, "2026-06-16T00:00:00Z");
202        assert_eq!(entry.expires_at, "2026-06-16T02:00:00Z");
203        assert_eq!(entry.issuer, "age1owner");
204
205        // A private copy is staged for the agent on the granted key only.
206        assert_eq!(
207            current.private["STRIPE_KEY"]["age1agent"].as_str(),
208            "sk_live_1"
209        );
210        assert!(!current.private.contains_key("OTHER"));
211        assert!(current.grants.contains_key("codex"));
212    }
213
214    #[test]
215    fn create_grant_rejects_unknown_key() {
216        let mut current = murk_with(&[("STRIPE_KEY", "sk")]);
217        let issued = Utc::now();
218        let err = create_grant(
219            &mut current,
220            "codex",
221            "age1agent",
222            &["NOPE".into()],
223            "age1owner",
224            issued,
225            Duration::hours(1),
226        )
227        .unwrap_err();
228        assert!(err.to_string().contains("NOPE"));
229        // Nothing recorded on failure.
230        assert!(current.grants.is_empty());
231    }
232
233    #[test]
234    fn create_grant_rejects_duplicate_name() {
235        let mut current = murk_with(&[("K", "v")]);
236        let issued = Utc::now();
237        create_grant(
238            &mut current,
239            "dup",
240            "age1a",
241            &["K".into()],
242            "age1owner",
243            issued,
244            Duration::hours(1),
245        )
246        .unwrap();
247        assert!(
248            create_grant(
249                &mut current,
250                "dup",
251                "age1b",
252                &["K".into()],
253                "age1owner",
254                issued,
255                Duration::hours(1),
256            )
257            .is_err()
258        );
259    }
260
261    #[test]
262    fn remove_grant_returns_metadata() {
263        let mut current = murk_with(&[("K", "v")]);
264        create_grant(
265            &mut current,
266            "g",
267            "age1a",
268            &["K".into()],
269            "age1owner",
270            Utc::now(),
271            Duration::hours(1),
272        )
273        .unwrap();
274        let removed = remove_grant(&mut current, "g").unwrap();
275        assert_eq!(removed.pubkey, "age1a");
276        assert!(current.grants.is_empty());
277        assert!(remove_grant(&mut current, "g").is_err());
278    }
279}