Skip to main content

mkit_cli/commands/
epoch.rs

1//! `mkit epoch show|bump` (WP-2.14, R-156): read and advance a namespace's
2//! grant epoch at a deployment (SPEC-WRITE-GRANTS §5).
3//!
4//! Both RPCs are unsigned transport: no auth v2 envelope and no
5//! `X-Repository`. `bump` signs a `mkit-write-epoch:v1` statement with the
6//! owner key and re-sends the identical bytes while the server answers
7//! `unavailable` + `Retry-After`, so a retry never starts a second
8//! revocation.
9
10use clap::{Args, Parser, Subcommand};
11use mkit_attest::grant::{
12    EpochStatement, EpochTransition, MAX_EPOCH_STEP, Namespace, epoch_transition,
13};
14use std::io::Write as _;
15
16use crate::clap_shim;
17use crate::commands::{error, usage_error};
18use crate::exit;
19use crate::format::{JsonObject, json_string_array};
20use crate::grants::cli::{Ctx, finish_wait, parse_namespace, parse_timeout, print_statement};
21use crate::grants::owner::{Kind, OwnerArgs, Produced, produce};
22use crate::grants::remote::{Driven, check_audiences, drive, interruptible_sleep, resolve_target};
23use crate::grants::spec::{build_epoch, canonical_audiences, statement_lifetime_ms};
24use crate::grants::store::{GrantStore, StoredGrant};
25use crate::grants::{now_ms, scope_text};
26
27#[derive(Debug, Parser)]
28#[command(
29    name = "mkit epoch",
30    about = "Show or advance a namespace's grant epoch on a remote."
31)]
32struct EpochOpts {
33    #[command(subcommand)]
34    command: EpochCommand,
35}
36
37#[derive(Debug, Subcommand)]
38enum EpochCommand {
39    /// Show the epoch a remote stores for a namespace.
40    Show(ShowOpts),
41    /// Advance the epoch, revoking every grant issued at a lower one.
42    Bump(BumpOpts),
43}
44
45#[derive(Debug, Args)]
46struct ShowOpts {
47    /// Remote name or mkit+https:// URL (default: the trusted remote).
48    remote: Option<String>,
49    /// Namespace to read (default: the remote URL's, else the signing key's).
50    #[arg(long, value_name = "NS")]
51    namespace: Option<String>,
52    /// Emit a JSON object.
53    #[arg(long)]
54    json: bool,
55}
56
57/// Flags of `mkit epoch bump`, also driving `mkit grant revoke`.
58#[derive(Debug, Clone, Args)]
59pub(crate) struct BumpOpts {
60    /// Remote name or mkit+https:// URL.
61    pub remote: Option<String>,
62    /// Namespace to advance (default: the remote URL's, else the signing key's).
63    #[arg(long, value_name = "NS")]
64    pub namespace: Option<String>,
65    /// How far to advance (1 to 1024).
66    #[arg(long, value_name = "N", default_value_t = 1)]
67    pub by: u64,
68    /// Audience the statement is valid for (repeatable; default: the remote's
69    /// origin, the audience its requests are signed for).
70    #[arg(long, value_name = "ORIGIN")]
71    pub audience: Vec<String>,
72    /// Longest to wait for revocation to complete (for example 5m).
73    #[arg(long, value_name = "DURATION", default_value = "5m")]
74    pub timeout: String,
75    /// Emit a JSON object.
76    #[arg(long)]
77    pub json: bool,
78    #[command(flatten)]
79    pub owner: OwnerArgs,
80}
81
82#[must_use]
83pub fn run(args: &[String]) -> u8 {
84    let opts = match clap_shim::parse::<EpochOpts>("mkit epoch", args) {
85        Ok(opts) => opts,
86        Err(code) => return code,
87    };
88    match opts.command {
89        EpochCommand::Show(opts) => show(&opts),
90        EpochCommand::Bump(opts) => bump(&opts, None),
91    }
92}
93
94/// `--namespace`, else the namespace of the repository the remote URL names.
95fn hint_namespace(
96    flag: Option<&str>,
97    target: &crate::grants::remote::Target,
98) -> Result<Option<Namespace>, u8> {
99    Ok(parse_namespace(flag)?.or_else(|| target.namespace()))
100}
101
102fn show(opts: &ShowOpts) -> u8 {
103    let ctx = match Ctx::load() {
104        Ok(ctx) => ctx,
105        Err(code) => return code,
106    };
107    let target = match resolve_target(&ctx.layered, opts.remote.as_deref()) {
108        Ok(target) => target,
109        Err(e) => return usage_error(&e),
110    };
111    let namespace = match hint_namespace(opts.namespace.as_deref(), &target) {
112        Ok(Some(ns)) => ns,
113        Ok(None) => {
114            // Fall back to the configured signing key's namespace.
115            let plan = ctx.plan(&OwnerArgs::default(), None);
116            match plan {
117                Ok(crate::grants::owner::Plan::Native(owner)) => *owner.namespace(),
118                _ => {
119                    return usage_error(
120                        "no namespace: pass --namespace, or a remote URL that names <namespace>/<repo>",
121                    );
122                }
123            }
124        }
125        Err(code) => return code,
126    };
127    let tx = match ctx.open_unsigned(&target) {
128        Ok(tx) => tx,
129        Err(e) => return error(&e, exit::UNAVAILABLE),
130    };
131    let epoch = match Ctx::read_epoch(&tx, &namespace) {
132        Ok(epoch) => epoch,
133        Err(e) => return error(&e, exit::UNAVAILABLE),
134    };
135    let mut stdout = std::io::stdout().lock();
136    if opts.json {
137        let mut object = JsonObject::new();
138        object
139            .field_str("namespace", &namespace.to_string())
140            .field_str("audience", tx.origin())
141            .field_u64("epoch", epoch);
142        let _ = writeln!(stdout, "{}", object.finish());
143    } else {
144        let _ = writeln!(stdout, "namespace {namespace}");
145        let _ = writeln!(stdout, "audience  {}", tx.origin());
146        let _ = writeln!(stdout, "epoch     {epoch}");
147    }
148    exit::OK
149}
150
151/// What `mkit grant revoke` adds around a bump.
152pub(crate) struct RevokeExtras {
153    pub store: GrantStore,
154    pub prune: bool,
155}
156
157/// Local grants a bump to `new_epoch` touches, split by whether every audience
158/// of the grant is covered by the bump's `audiences`.
159struct Invalidated<'a> {
160    /// Every audience is covered: the grant stops working everywhere.
161    dead: Vec<&'a StoredGrant>,
162    /// Some audience is not covered: still valid there, and kept.
163    partial: Vec<&'a StoredGrant>,
164}
165
166/// Grants in `stored` of `namespace` below `new_epoch` that list at least one
167/// of the bump's `audiences`.
168fn invalidated<'a>(
169    stored: &'a [StoredGrant],
170    namespace: &Namespace,
171    audiences: &[String],
172    new_epoch: u64,
173) -> Invalidated<'a> {
174    let mut out = Invalidated {
175        dead: Vec::new(),
176        partial: Vec::new(),
177    };
178    for g in stored.iter().filter(|g| {
179        g.grant.namespace == *namespace
180            && g.grant.epoch < new_epoch
181            && g.grant.audiences.iter().any(|a| audiences.contains(a))
182    }) {
183        if g.grant.audiences.iter().all(|a| audiences.contains(a)) {
184            out.dead.push(g);
185        } else {
186            out.partial.push(g);
187        }
188    }
189    out
190}
191
192fn grant_line(g: &StoredGrant) -> String {
193    format!(
194        "{}  {}  epoch {}  {}",
195        mkit_core::hash::to_hex_bytes(&g.id),
196        g.grant.capabilities.token(),
197        g.grant.epoch,
198        scope_text(&g.grant)
199    )
200}
201
202#[allow(clippy::too_many_lines)] // linear flow: resolve, sign once, send until done, report
203pub(crate) fn bump(opts: &BumpOpts, revoke: Option<&RevokeExtras>) -> u8 {
204    let ctx = match Ctx::load() {
205        Ok(ctx) => ctx,
206        Err(code) => return code,
207    };
208    let timeout = match parse_timeout(&opts.timeout) {
209        Ok(t) => t,
210        Err(e) => return usage_error(&format!("--timeout: {e}")),
211    };
212    if opts.by == 0 || opts.by > MAX_EPOCH_STEP {
213        return usage_error(&format!(
214            "--by {} is outside 1..={MAX_EPOCH_STEP}, the largest epoch step a server accepts (SPEC-WRITE-GRANTS §5.2); bump in several steps",
215            opts.by
216        ));
217    }
218    let Some(remote) = opts.remote.as_deref() else {
219        return usage_error("mkit epoch bump needs a remote (name or mkit+https:// URL)");
220    };
221    let target = match resolve_target(&ctx.layered, Some(remote)) {
222        Ok(target) => target,
223        Err(e) => return usage_error(&e),
224    };
225    let tx = match ctx.open_unsigned(&target) {
226        Ok(tx) => tx,
227        Err(e) => return error(&e, exit::UNAVAILABLE),
228    };
229    let audiences = if opts.audience.is_empty() {
230        vec![tx.origin().to_owned()]
231    } else {
232        match canonical_audiences(&opts.audience) {
233            Ok(a) => a,
234            Err(e) => return usage_error(&e),
235        }
236    };
237    if let Err(e) = check_audiences(&audiences, Some(&target)) {
238        return error(&e, exit::USAGE);
239    }
240    let hint = match hint_namespace(opts.namespace.as_deref(), &target) {
241        Ok(hint) => hint,
242        Err(code) => return code,
243    };
244    let plan = match ctx.plan(&opts.owner, hint) {
245        Ok(plan) => plan,
246        Err(e) => return error(&e, exit::USAGE),
247    };
248
249    // Native and print plans build the statement from the stored epoch.
250    let mut read_current: Option<u64> = None;
251    let now = now_ms();
252    let lifetime_ms = statement_lifetime_ms(timeout);
253    let produced = produce(
254        plan,
255        |ns| {
256            let current = Ctx::read_epoch(&tx, ns)?;
257            read_current = Some(current);
258            let new = current
259                .checked_add(opts.by)
260                .ok_or("the epoch would overflow")?;
261            build_epoch(ns, new, &audiences, now, lifetime_ms)?
262                .encode()
263                .map_err(|e| format!("invalid statement: {e}"))
264        },
265        Kind::Epoch,
266        &ctx.relying_parties,
267        now,
268    );
269    let signed = match produced {
270        Ok(Produced::Signed(signed)) => signed,
271        Ok(Produced::Print {
272            statement,
273            namespace,
274        }) => return print_statement(&statement, &namespace),
275        Err(e) => return error(&e, exit::DATAERR),
276    };
277    let statement = match EpochStatement::parse(&signed.statement) {
278        Ok(s) => s,
279        Err(e) => return error(&format!("invalid statement: {e}"), exit::DATAERR),
280    };
281    // An imported statement was made elsewhere: hold what it says, not the
282    // flags, to the same audience and namespace rules.
283    if let Err(e) = check_audiences(&statement.audiences, Some(&target)) {
284        return error(&e, exit::USAGE);
285    }
286    if let Some(hint) = hint
287        && hint != statement.namespace
288    {
289        return error(
290            &format!(
291                "the statement is for namespace {}, but the command asks for {hint}",
292                statement.namespace
293            ),
294            exit::DATAERR,
295        );
296    }
297    let current = match read_current {
298        Some(c) => c,
299        None => match Ctx::read_epoch(&tx, &statement.namespace) {
300            Ok(c) => c,
301            Err(e) => return error(&e, exit::UNAVAILABLE),
302        },
303    };
304    match epoch_transition(current, statement.new_epoch) {
305        EpochTransition::Advance | EpochTransition::Retry => {}
306        EpochTransition::Reject if statement.new_epoch > current => {
307            return error(
308                &format!(
309                    "the statement raises epoch {current} to {}: a step of {} is over the maximum of {MAX_EPOCH_STEP}",
310                    statement.new_epoch,
311                    statement.new_epoch - current
312                ),
313                exit::DATAERR,
314            );
315        }
316        EpochTransition::Reject => {
317            return error(
318                &format!(
319                    "the statement sets epoch {} but {} is already stored; an epoch never decreases",
320                    statement.new_epoch, current
321                ),
322                exit::DATAERR,
323            );
324        }
325    }
326
327    let stored = revoke.map(|r| r.store.load(&ctx.relying_parties));
328    if let Some(report) = &stored {
329        for warning in &report.warnings {
330            eprintln!("warning: {warning}");
331        }
332        let affected = invalidated(
333            &report.grants,
334            &statement.namespace,
335            &statement.audiences,
336            statement.new_epoch,
337        );
338        eprintln!(
339            "revoking: {} local grant(s) stop working when {} reaches epoch {}",
340            affected.dead.len() + affected.partial.len(),
341            statement.namespace,
342            statement.new_epoch
343        );
344        for g in &affected.dead {
345            eprintln!("  {}", grant_line(g));
346        }
347        for g in &affected.partial {
348            eprintln!(
349                "  {}  (still valid at {})",
350                grant_line(g),
351                g.grant
352                    .audiences
353                    .iter()
354                    .filter(|a| !statement.audiences.contains(a))
355                    .cloned()
356                    .collect::<Vec<_>>()
357                    .join(", ")
358            );
359        }
360    }
361
362    let outcome = drive(
363        || tx.set_grant_epoch(&signed.header),
364        timeout,
365        interruptible_sleep,
366    );
367    let outcome = match outcome {
368        Ok(outcome) => outcome,
369        Err(e) => return set_epoch_error(&e),
370    };
371    if let Some(code) = finish_wait(&outcome, "the epoch bump") {
372        return code;
373    }
374    let Driven::Done(stored_epoch) = outcome else {
375        return exit::GENERAL_ERROR;
376    };
377    report_bump(
378        opts,
379        &statement,
380        current,
381        stored_epoch,
382        revoke,
383        stored.as_ref().map(|r| r.grants.as_slice()),
384    )
385}
386
387#[allow(clippy::too_many_arguments)]
388fn report_bump(
389    opts: &BumpOpts,
390    statement: &EpochStatement,
391    previous: u64,
392    stored_epoch: u64,
393    revoke: Option<&RevokeExtras>,
394    grants: Option<&[StoredGrant]>,
395) -> u8 {
396    let mut pruned = 0usize;
397    if let (Some(extras), Some(grants)) = (revoke, grants)
398        && extras.prune
399    {
400        for g in invalidated(
401            grants,
402            &statement.namespace,
403            &statement.audiences,
404            stored_epoch,
405        )
406        .dead
407        {
408            match extras.store.remove(&g.id) {
409                Ok(true) => pruned += 1,
410                Ok(false) => {}
411                Err(e) => eprintln!("warning: could not remove grant: {e}"),
412            }
413        }
414    }
415    let mut stdout = std::io::stdout().lock();
416    if opts.json {
417        let mut object = JsonObject::new();
418        object
419            .field_str("namespace", &statement.namespace.to_string())
420            .field_raw("audiences", &json_string_array(&statement.audiences))
421            .field_u64("previous", previous)
422            .field_u64("epoch", stored_epoch);
423        if revoke.is_some() {
424            object.field_u64("pruned", pruned as u64);
425        }
426        let _ = writeln!(stdout, "{}", object.finish());
427    } else {
428        let _ = writeln!(stdout, "namespace {}", statement.namespace);
429        let _ = writeln!(stdout, "audience  {}", statement.audiences.join(", "));
430        let _ = writeln!(stdout, "epoch     {stored_epoch} (was {previous})");
431    }
432    if revoke.is_some() {
433        eprintln!(
434            "grants issued at epoch {previous} or lower no longer work. Issue replacements with `mkit grant create --epoch {stored_epoch} ...`{}",
435            if pruned > 0 {
436                format!("; removed {pruned} local grant(s)")
437            } else {
438                String::new()
439            }
440        );
441    }
442    exit::OK
443}
444
445fn set_epoch_error(error_value: &mkit_core::protocol::TransportError) -> u8 {
446    use mkit_core::protocol::TransportError;
447    let hint = match error_value {
448        TransportError::AccessDenied => {
449            " (the server rejected the statement: a step over 1024, a decrease, a wrong audience, an expired statement, a bad signature, or a namespace this deployment doesn't serve)"
450        }
451        _ => "",
452    };
453    error(&format!("SetGrantEpoch: {error_value}{hint}"), exit::NOPERM)
454}
455
456#[cfg(test)]
457mod tests {
458    use super::*;
459    use crate::grants::store::GrantStore;
460    use crate::grants::testutil::signed_grant;
461
462    fn stored(dir: &std::path::Path, nonce: u8, epoch: u64, audiences: &[&str]) -> StoredGrant {
463        // `signed_grant` takes one audience; rebuild a multi-audience grant
464        // by signing it with the first and re-reading its fields.
465        let header = signed_grant(1, nonce, epoch, audiences[0]);
466        let store = GrantStore::at(dir.to_path_buf());
467        store.add(&header, &[]).unwrap();
468        let mut g = store
469            .load(&[])
470            .grants
471            .into_iter()
472            .find(|g| g.grant.epoch == epoch && g.header == header)
473            .unwrap();
474        g.grant.audiences = audiences.iter().map(|a| (*a).to_owned()).collect();
475        g
476    }
477
478    #[test]
479    fn a_bump_only_kills_grants_it_covers_in_full() {
480        let tmp = tempfile::tempdir().unwrap();
481        let a = "https://a.example";
482        let b = "https://b.example";
483        let all = vec![
484            stored(tmp.path(), 1, 0, &[a]),
485            stored(tmp.path(), 2, 1, &[a, b]),
486            stored(tmp.path(), 3, 2, &[b]),
487            stored(tmp.path(), 4, 5, &[a]),
488        ];
489        let namespace = all[0].grant.namespace;
490        let hit = invalidated(&all, &namespace, &[a.to_owned()], 3);
491        assert_eq!(hit.dead.len(), 1);
492        assert_eq!(hit.dead[0].grant.epoch, 0);
493        // Epoch 1 also lists b, which the bump does not reach: kept.
494        assert_eq!(hit.partial.len(), 1);
495        assert_eq!(hit.partial[0].grant.epoch, 1);
496        // Covering both audiences kills it.
497        let hit = invalidated(&all, &namespace, &[a.to_owned(), b.to_owned()], 3);
498        assert_eq!(hit.dead.len(), 3);
499        assert!(hit.partial.is_empty());
500    }
501}