From aa05f676f7acdd747b8f7593b8f57bc38e53a5b9 Mon Sep 17 00:00:00 2001 From: Fintan Halpenny Date: Wed, 30 Nov 2022 14:09:07 +0000 Subject: [PATCH] cli: add delegate cli Adds a `rad delegate` CLI command. The command consists of three subcommands: * `add` -- add a new delegate to the set of delegates of a project * `remove` -- remove an existing delegate from the set of delegates of a project * `list` -- list the delegates of a project Signed-off-by: Fintan Halpenny X-Clacks-Overhead: GNU Terry Pratchett --- radicle-cli/examples/rad-delegate.md | 46 +++++++ radicle-cli/src/commands.rs | 2 + radicle-cli/src/commands/delegate.rs | 141 ++++++++++++++++++++ radicle-cli/src/commands/delegate/add.rs | 51 +++++++ radicle-cli/src/commands/delegate/list.rs | 16 +++ radicle-cli/src/commands/delegate/remove.rs | 54 ++++++++ radicle-cli/src/main.rs | 8 ++ radicle-cli/tests/commands.rs | 15 +++ radicle/src/identity.rs | 4 +- radicle/src/identity/did.rs | 10 +- radicle/src/identity/doc.rs | 26 +++- 11 files changed, 367 insertions(+), 6 deletions(-) create mode 100644 radicle-cli/examples/rad-delegate.md create mode 100644 radicle-cli/src/commands/delegate.rs create mode 100644 radicle-cli/src/commands/delegate/add.rs create mode 100644 radicle-cli/src/commands/delegate/list.rs create mode 100644 radicle-cli/src/commands/delegate/remove.rs diff --git a/radicle-cli/examples/rad-delegate.md b/radicle-cli/examples/rad-delegate.md new file mode 100644 index 00000000..886b8ec1 --- /dev/null +++ b/radicle-cli/examples/rad-delegate.md @@ -0,0 +1,46 @@ +Delegates are the authorized keys that can manage a project's +metadata, including adding a new delegate. + +Let's list the current set of delegates for a project. + +``` +$ rad delegate list rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji +[ + "did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi" +] +``` + +We want to add a new maintainer to the project to help out with the +work. + +``` +$ rad delegate add z6MkjchhfUsD6mmvni8mCdXHw216Xrm9bQe2mBH1P5RDjVJG --to rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji +Added delegate 'did:key:z6MkjchhfUsD6mmvni8mCdXHw216Xrm9bQe2mBH1P5RDjVJG' +ok Update successful! +``` + +Let's convince ourselves that there's another delegate. + +``` +$ rad delegate list rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji +[ + "did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi", + "did:key:z6MkjchhfUsD6mmvni8mCdXHw216Xrm9bQe2mBH1P5RDjVJG" +] +``` + +And finally, we no longer want to be part of the project so we pass on +the torch and remove ourselves from the delegate set. + +``` +$ rad delegate remove z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi --to rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji +Removed delegate 'did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi' +ok Update successful! +``` + +``` +$ rad delegate list rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji +[ + "did:key:z6MkjchhfUsD6mmvni8mCdXHw216Xrm9bQe2mBH1P5RDjVJG" +] +``` diff --git a/radicle-cli/src/commands.rs b/radicle-cli/src/commands.rs index 405a5df9..51e34a37 100644 --- a/radicle-cli/src/commands.rs +++ b/radicle-cli/src/commands.rs @@ -6,6 +6,8 @@ pub mod rad_auth; pub mod rad_checkout; #[path = "commands/clone.rs"] pub mod rad_clone; +#[path = "commands/delegate.rs"] +pub mod rad_delegate; #[path = "commands/edit.rs"] pub mod rad_edit; #[path = "commands/help.rs"] diff --git a/radicle-cli/src/commands/delegate.rs b/radicle-cli/src/commands/delegate.rs new file mode 100644 index 00000000..0257ab45 --- /dev/null +++ b/radicle-cli/src/commands/delegate.rs @@ -0,0 +1,141 @@ +use std::ffi::OsString; +use std::str::FromStr; + +use anyhow::{anyhow, Context as _}; + +use radicle::identity::Id; +use radicle_crypto::PublicKey; + +use crate::terminal as term; +use crate::terminal::args::{Args, Error, Help}; + +#[path = "delegate/add.rs"] +mod add; +#[path = "delegate/list.rs"] +mod list; +#[path = "delegate/remove.rs"] +mod remove; + +pub const HELP: Help = Help { + name: "delegate", + description: "Manage the delegates of an identity", + version: env!("CARGO_PKG_VERSION"), + usage: r#" +Usage + + rad delegate (add|remove) [--to ] + rad delegate list [] + + The `add` and `remove` commands are limited to managing delegates + where the `threshold` for the quorum is exactly `1`. Otherwise, + the verification of the document will not be able to gather enough + signatures to pass the quorum. + +Options + + --help Print help +"#, +}; + +#[derive(Debug, Default, PartialEq, Eq)] +pub enum OperationName { + Add, + Remove, + #[default] + List, +} + +#[derive(Debug, Eq, PartialEq)] +pub enum Operation { + Add { id: Option, key: PublicKey }, + Remove { id: Option, key: PublicKey }, + List { id: Option }, +} + +#[derive(Debug, Eq, PartialEq)] +pub struct Options { + pub op: Operation, +} + +impl Args for Options { + fn from_args(args: Vec) -> anyhow::Result<(Self, Vec)> { + use lexopt::prelude::*; + + let mut parser = lexopt::Parser::from_args(args); + let mut id: Option = None; + let mut op: Option = None; + let mut key: Option = None; + + while let Some(arg) = parser.next()? { + match arg { + Long("help") => { + return Err(Error::Help.into()); + } + Long("to") => { + id = Some(parser.value()?.parse::()?); + } + Value(val) if op.is_none() => match val.to_string_lossy().as_ref() { + "a" | "add" => op = Some(OperationName::Add), + "r" | "remove" => op = Some(OperationName::Remove), + "l" | "list" => op = Some(OperationName::List), + + unknown => anyhow::bail!("unknown operation '{}'", unknown), + }, + Value(val) if op.is_some() => { + let val = val.to_string_lossy(); + + match op { + Some(OperationName::Add) | Some(OperationName::Remove) => { + if let Ok(val) = PublicKey::from_str(&val) { + key = Some(val); + } else { + return Err(anyhow!("invalid Public Key '{}'", val)); + } + } + Some(OperationName::List) => { + if let Ok(val) = Id::from_str(&val) { + id = Some(val); + } else { + return Err(anyhow!("invalid Project ID '{}'", val)); + } + } + None => continue, + } + } + _ => return Err(anyhow!(arg.unexpected())), + } + } + + let op = match op.unwrap_or_default() { + OperationName::List => Operation::List { id }, + OperationName::Add => Operation::Add { + id, + key: key.ok_or_else(|| anyhow!("a delegate key must be provided"))?, + }, + OperationName::Remove => Operation::Remove { + id, + key: key.ok_or_else(|| anyhow!("a delegate key must be provided"))?, + }, + }; + + Ok((Options { op }, vec![])) + } +} + +pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> { + let profile = ctx.profile()?; + let storage = &profile.storage; + + match options.op { + Operation::Add { id, key } => add::run(&profile, storage, get_id(id)?, key)?, + Operation::Remove { id, key } => remove::run(&profile, storage, get_id(id)?, &key)?, + Operation::List { id } => list::run(&profile, storage, get_id(id)?)?, + } + + Ok(()) +} + +fn get_id(id: Option) -> anyhow::Result { + id.or_else(|| radicle::rad::cwd().ok().map(|(_, id)| id)) + .context("Couldn't get ID from either command line or cwd") +} diff --git a/radicle-cli/src/commands/delegate/add.rs b/radicle-cli/src/commands/delegate/add.rs new file mode 100644 index 00000000..9e963625 --- /dev/null +++ b/radicle-cli/src/commands/delegate/add.rs @@ -0,0 +1,51 @@ +use anyhow::Context as _; +use radicle::{ + prelude::{Did, Id}, + storage::{WriteRepository as _, WriteStorage}, + Profile, +}; +use radicle_crypto::PublicKey; + +use crate::terminal as term; + +pub fn run(profile: &Profile, storage: &S, id: Id, key: PublicKey) -> anyhow::Result<()> +where + S: WriteStorage, +{ + let signer = term::signer(profile)?; + let me = signer.public_key(); + + let mut project = storage + .get(&profile.public_key, id)? + .context("No project with such ID exists")?; + + let repo = storage.repository(id)?; + + if !project.is_delegate(me) { + return Err(anyhow::anyhow!( + "'{}' is not a delegate of the project, only a delegate may add this key", + me + )); + } + + if project.threshold > 1 { + return Err(anyhow::anyhow!("project threshold > 1")); + } + + if project.delegate(&key) { + project.sign(&signer).and_then(|(_, sig)| { + project.update( + signer.public_key(), + "Updated payload", + &[(signer.public_key(), sig)], + repo.raw(), + ) + })?; + term::info!("Added delegate '{}'", Did::from(key)); + term::success!("Update successful!"); + Ok(()) + } else { + term::info!("the delegate for '{}' already exists", key); + Ok(()) + } +} diff --git a/radicle-cli/src/commands/delegate/list.rs b/radicle-cli/src/commands/delegate/list.rs new file mode 100644 index 00000000..73ae2f47 --- /dev/null +++ b/radicle-cli/src/commands/delegate/list.rs @@ -0,0 +1,16 @@ +use anyhow::Context as _; +use radicle::{prelude::Id, storage::ReadStorage, Profile}; + +use crate::terminal as term; + +pub fn run(profile: &Profile, storage: &S, id: Id) -> anyhow::Result<()> +where + S: ReadStorage, +{ + let project = storage + .get(&profile.public_key, id)? + .context("No project with such ID exists")?; + + term::info!("{}", serde_json::to_string_pretty(&project.delegates)?); + Ok(()) +} diff --git a/radicle-cli/src/commands/delegate/remove.rs b/radicle-cli/src/commands/delegate/remove.rs new file mode 100644 index 00000000..840edc5c --- /dev/null +++ b/radicle-cli/src/commands/delegate/remove.rs @@ -0,0 +1,54 @@ +use anyhow::Context as _; +use radicle::{ + prelude::Id, + storage::{WriteRepository as _, WriteStorage}, + Profile, +}; +use radicle_crypto::PublicKey; + +use crate::terminal as term; + +pub fn run(profile: &Profile, storage: &S, id: Id, key: &PublicKey) -> anyhow::Result<()> +where + S: WriteStorage, +{ + let signer = term::signer(profile)?; + let me = signer.public_key(); + + let mut project = storage + .get(&profile.public_key, id)? + .context("No project with such ID exists")?; + + let repo = storage.repository(id)?; + + if !project.is_delegate(me) { + return Err(anyhow::anyhow!( + "'{}' is not a delegate of the project, only a delegate may remove this key", + me + )); + } + + if project.threshold > 1 { + return Err(anyhow::anyhow!("project threshold > 1")); + } + + match project.rescind(key)? { + Some(delegate) => { + project.sign(&signer).and_then(|(_, sig)| { + project.update( + signer.public_key(), + "Updated payload", + &[(signer.public_key(), sig)], + repo.raw(), + ) + })?; + term::info!("Removed delegate '{}'", delegate); + term::success!("Update successful!"); + Ok(()) + } + None => { + term::info!("the delegate for '{}' did not exist", key); + Ok(()) + } + } +} diff --git a/radicle-cli/src/main.rs b/radicle-cli/src/main.rs index b4720eb0..51695e11 100644 --- a/radicle-cli/src/main.rs +++ b/radicle-cli/src/main.rs @@ -134,6 +134,14 @@ fn run_other(exe: &str, args: &[OsString]) -> Result<(), Option> args.to_vec(), ); } + "delegate" => { + term::run_command_args::( + rad_delegate::HELP, + "Delegate", + rad_delegate::run, + args.to_vec(), + ); + } "edit" => { term::run_command_args::( rad_edit::HELP, diff --git a/radicle-cli/tests/commands.rs b/radicle-cli/tests/commands.rs index d001b46d..ac9bff51 100644 --- a/radicle-cli/tests/commands.rs +++ b/radicle-cli/tests/commands.rs @@ -72,3 +72,18 @@ fn rad_init() { test("examples/rad-init.md", Some(&profile)).unwrap(); } + +#[test] +fn rad_delegate() { + let home = tempfile::tempdir().unwrap(); + let working = tempfile::tempdir().unwrap(); + let profile = profile(home.path()); + + // Setup a test repository. + fixtures::repository(working.path()); + // Navigate to repository. + env::set_current_dir(working.path()).unwrap(); + + test("examples/rad-init.md", Some(&profile)).unwrap(); + test("examples/rad-delegate.md", Some(&profile)).unwrap(); +} diff --git a/radicle/src/identity.rs b/radicle/src/identity.rs index 94276aac..1888a690 100644 --- a/radicle/src/identity.rs +++ b/radicle/src/identity.rs @@ -213,7 +213,7 @@ mod test { .unwrap(); // Add Bob as a delegate, and sign it. - doc.delegate(*bob.public_key()); + doc.delegate(bob.public_key()); doc.threshold = 2; doc.sign(&alice) .and_then(|(_, sig)| { @@ -227,7 +227,7 @@ mod test { .unwrap(); // Add Eve as a delegate, and sign it. - doc.delegate(*eve.public_key()); + doc.delegate(eve.public_key()); doc.sign(&alice) .and_then(|(_, alice_sig)| { doc.sign(&bob).and_then(|(_, bob_sig)| { diff --git a/radicle/src/identity/did.rs b/radicle/src/identity/did.rs index 3a6edc75..d01078f3 100644 --- a/radicle/src/identity/did.rs +++ b/radicle/src/identity/did.rs @@ -14,7 +14,7 @@ pub enum DidError { PublicKey(#[from] crypto::PublicKeyError), } -#[derive(Serialize, Deserialize, PartialEq, Eq, Hash, Clone)] +#[derive(Serialize, Deserialize, PartialEq, Eq, Hash, Clone, Copy)] #[serde(into = "String", try_from = "String")] pub struct Did(crypto::PublicKey); @@ -34,9 +34,15 @@ impl Did { } } +impl From<&crypto::PublicKey> for Did { + fn from(key: &crypto::PublicKey) -> Self { + Self(*key) + } +} + impl From for Did { fn from(key: crypto::PublicKey) -> Self { - Self(key) + (&key).into() } } diff --git a/radicle/src/identity/doc.rs b/radicle/src/identity/doc.rs index 1a8bff74..23f6c9b6 100644 --- a/radicle/src/identity/doc.rs +++ b/radicle/src/identity/doc.rs @@ -4,7 +4,7 @@ use std::collections::{BTreeMap, HashMap}; use std::fmt; use std::fmt::Write as _; use std::marker::PhantomData; -use std::ops::Deref; +use std::ops::{Deref, Not}; use std::path::Path; use nonempty::NonEmpty; @@ -150,6 +150,10 @@ impl Doc { repo.blob_at(commit, Path::new(&*PATH)) .map_err(DocError::from) } + + pub fn is_delegate(&self, key: &crypto::PublicKey) -> bool { + self.delegates.contains(&key.into()) + } } impl Doc { @@ -165,7 +169,7 @@ impl Doc { } /// Attempt to add a new delegate to the document. Returns `true` if it wasn't there before. - pub fn delegate(&mut self, key: crypto::PublicKey) -> bool { + pub fn delegate(&mut self, key: &crypto::PublicKey) -> bool { let delegate = Did::from(key); if self.delegates.iter().all(|id| id != &delegate) { self.delegates.push(delegate); @@ -174,6 +178,24 @@ impl Doc { false } + pub fn rescind(&mut self, key: &crypto::PublicKey) -> Result, DocError> { + let delegate = Did::from(key); + let (matches, delegates) = self.delegates.iter().partition(|d| **d == delegate); + match NonEmpty::from_vec(delegates) { + Some(delegates) => { + self.delegates = delegates; + if self.threshold > self.delegates.len() { + return Err(DocError::Threshold( + self.threshold, + "the thresholds exceeds the new delegate count after removal", + )); + } + Ok(matches.is_empty().not().then_some(delegate)) + } + None => Err(DocError::Delegates("cannot remove the last delegate")), + } + } + /// Get the project payload, if it exists and is valid, out of this document. pub fn project(&self) -> Result { let value = self