summaryrefslogtreecommitdiff
path: root/src/cmd/id
diff options
context:
space:
mode:
Diffstat (limited to 'src/cmd/id')
-rw-r--r--src/cmd/id/edit.rs209
-rw-r--r--src/cmd/id/init.rs230
-rw-r--r--src/cmd/id/show.rs75
-rw-r--r--src/cmd/id/sign.rs221
4 files changed, 735 insertions, 0 deletions
diff --git a/src/cmd/id/edit.rs b/src/cmd/id/edit.rs
new file mode 100644
index 0000000..02687b8
--- /dev/null
+++ b/src/cmd/id/edit.rs
@@ -0,0 +1,209 @@
+// Copyright © 2022 Kim Altintop <kim@eagain.io>
+// SPDX-License-Identifier: GPL-2.0-only WITH openvpn-openssl-exception
+
+use std::{
+ fs::File,
+ iter,
+ path::Path,
+};
+
+use anyhow::{
+ anyhow,
+ bail,
+ ensure,
+ Context,
+};
+
+use super::{
+ Common,
+ Editable,
+ META_FILE_ID,
+};
+use crate::{
+ cfg,
+ cmd::{
+ self,
+ args::Refname,
+ ui::{
+ self,
+ edit_commit_message,
+ edit_metadata,
+ info,
+ warn,
+ },
+ Aborted,
+ FromGit as _,
+ GitIdentity,
+ },
+ git::{
+ self,
+ refs,
+ },
+ json,
+ metadata::{
+ self,
+ Metadata,
+ },
+};
+
+#[derive(Debug, clap::Args)]
+#[allow(rustdoc::bare_urls)]
+pub struct Edit {
+ #[clap(flatten)]
+ common: Common,
+ /// Commit to this branch to propose the update
+ ///
+ /// If not given, the edit is performed in-place if the signature threshold
+ /// is met using the supplied keys.
+ #[clap(long, value_parser)]
+ propose_as: Option<Refname>,
+ /// Check out the committed changes
+ ///
+ /// Only has an effect if the repository is non-bare.
+ #[clap(long, value_parser)]
+ checkout: bool,
+ /// Don't commit anything to disk
+ #[clap(long, value_parser)]
+ dry_run: bool,
+ /// Commit message for this edit
+ ///
+ /// Like git, $EDITOR will be invoked if not specified.
+ #[clap(short, long, value_parser)]
+ message: Option<String>,
+}
+
+#[derive(serde::Serialize)]
+pub struct Output {
+ #[serde(rename = "ref")]
+ refname: Refname,
+ #[serde(with = "crate::git::serde::oid")]
+ commit: git2::Oid,
+}
+
+pub fn edit(args: Edit) -> cmd::Result<Output> {
+ let (repo, refname) = args.common.resolve()?;
+
+ let GitIdentity {
+ hash: parent_hash,
+ signed: metadata::Signed { signed: parent, .. },
+ } = metadata::Identity::from_tip(&repo, &refname)?;
+
+ let mut id: metadata::Identity = edit_metadata(Editable::from(parent.clone()))?.try_into()?;
+ if id.canonicalise()? == parent.canonicalise()? {
+ info!("Document unchanged");
+ cmd::abort!();
+ }
+ id.prev = Some(parent_hash.clone());
+
+ let cfg = repo.config()?;
+ let mut signer = cfg::signer(&cfg, ui::askpass)?;
+ let keyid = metadata::KeyId::from(signer.ident());
+ ensure!(
+ parent.keys.contains_key(&keyid) || id.keys.contains_key(&keyid),
+ "signing key {keyid} is not eligible to sign the document"
+ );
+ let signed = Metadata::identity(&id).sign(iter::once(&mut signer))?;
+
+ let commit_to = match id.verify(&signed.signatures, cmd::find_parent(&repo)) {
+ Ok(_) => args.propose_as.as_ref().unwrap_or(&refname),
+ Err(metadata::error::Verification::SignatureThreshold) => match &args.propose_as {
+ None => bail!("cannot update {refname} in place as signature threshold is not met"),
+ Some(tgt) => {
+ warn!("Signature threshold is not met");
+ tgt
+ },
+ },
+ Err(e) => bail!(e),
+ };
+
+ let mut tx = refs::Transaction::new(&repo)?;
+
+ let _tip = tx.lock_ref(refname.clone())?;
+ let tip = repo.find_reference(_tip.name())?;
+ let parent_commit = tip.peel_to_commit()?;
+ let parent_tree = parent_commit.tree()?;
+ // check that parent is valid
+ {
+ let entry = parent_tree.get_name(META_FILE_ID).ok_or_else(|| {
+ anyhow!("{refname} was modified concurrently, {META_FILE_ID} not found in tree")
+ })?;
+ ensure!(
+ parent_hash == entry.to_object(&repo)?.peel_to_blob()?.id(),
+ "{refname} was modified concurrently",
+ );
+ }
+ let commit_to = tx.lock_ref(commit_to.clone())?;
+ let on_head =
+ !repo.is_bare() && git2::Branch::wrap(repo.find_reference(commit_to.name())?).is_head();
+
+ let tree = if on_head {
+ write_tree(&repo, &signed)
+ } else {
+ write_tree_bare(&repo, &signed, Some(&parent_tree))
+ }?;
+ let msg = args
+ .message
+ .map(Ok)
+ .unwrap_or_else(|| edit_commit_message(&repo, commit_to.name(), &parent_tree, &tree))?;
+ let commit = git::commit_signed(&mut signer, &repo, msg, &tree, &[&parent_commit])?;
+ commit_to.set_target(commit, "it: edit identity");
+
+ tx.commit()?;
+
+ if args.checkout && repo.is_bare() {
+ bail!("repository is bare, refusing checkout");
+ }
+ if args.checkout || on_head {
+ repo.checkout_tree(
+ tree.as_object(),
+ Some(git2::build::CheckoutBuilder::new().safe()),
+ )?;
+ repo.set_head(commit_to.name())?;
+ info!("Switched to branch '{commit_to}'");
+ }
+
+ Ok(Output {
+ refname: commit_to.into(),
+ commit,
+ })
+}
+
+pub(super) fn write_tree<'a>(
+ repo: &'a git2::Repository,
+ meta: &metadata::Signed<metadata::Metadata>,
+) -> crate::Result<git2::Tree<'a>> {
+ ensure!(
+ repo.statuses(None)?.is_empty(),
+ "uncommitted changes in working tree. Please commit or stash them before proceeding"
+ );
+ let id_json = repo
+ .workdir()
+ .expect("non-bare repo ought to have a workdir")
+ .join(META_FILE_ID);
+ let out = File::options()
+ .write(true)
+ .truncate(true)
+ .open(&id_json)
+ .with_context(|| format!("error opening {} for writing", id_json.display()))?;
+ serde_json::to_writer_pretty(&out, meta)
+ .with_context(|| format!("serialising to {} failed", id_json.display()))?;
+
+ let mut index = repo.index()?;
+ index.add_path(Path::new(META_FILE_ID))?;
+ let oid = index.write_tree()?;
+
+ Ok(repo.find_tree(oid)?)
+}
+
+pub(super) fn write_tree_bare<'a>(
+ repo: &'a git2::Repository,
+ meta: &metadata::Signed<metadata::Metadata>,
+ from: Option<&git2::Tree>,
+) -> crate::Result<git2::Tree<'a>> {
+ let blob = json::to_blob(repo, meta)?;
+ let mut bld = repo.treebuilder(from)?;
+ bld.insert(META_FILE_ID, blob, git2::FileMode::Blob.into())?;
+ let oid = bld.write()?;
+
+ Ok(repo.find_tree(oid)?)
+}
diff --git a/src/cmd/id/init.rs b/src/cmd/id/init.rs
new file mode 100644
index 0000000..a0ed119
--- /dev/null
+++ b/src/cmd/id/init.rs
@@ -0,0 +1,230 @@
+// Copyright © 2022 Kim Altintop <kim@eagain.io>
+// SPDX-License-Identifier: GPL-2.0-only WITH openvpn-openssl-exception
+
+use core::{
+ iter,
+ num::NonZeroUsize,
+};
+use std::path::PathBuf;
+
+use anyhow::ensure;
+use clap::ValueHint;
+use url::Url;
+
+use super::{
+ Editable,
+ META_FILE_ID,
+};
+use crate::{
+ cfg::{
+ self,
+ paths,
+ },
+ cmd::{
+ self,
+ args::Refname,
+ ui::{
+ self,
+ edit_metadata,
+ info,
+ },
+ },
+ git::{
+ self,
+ if_not_found_none,
+ refs,
+ },
+ json,
+ metadata::{
+ self,
+ DateTime,
+ Key,
+ KeySet,
+ },
+};
+
+#[derive(Debug, clap::Args)]
+pub struct Init {
+ /// Path to the 'keyring' repository
+ #[clap(
+ long,
+ value_parser,
+ value_name = "DIR",
+ env = "GIT_DIR",
+ default_value_os_t = paths::ids(),
+ value_hint = ValueHint::DirPath,
+ )]
+ git_dir: PathBuf,
+ /// If the repository does not already exist, initialise it as non-bare
+ ///
+ /// Having the identity files checked out into a work tree may make it
+ /// easier to manipulate them with external tooling. Note, however, that
+ /// only committed files are considered by `it`.
+ #[clap(long, value_parser)]
+ no_bare: bool,
+ /// Set this identity as the default in the user git config
+ #[clap(long, value_parser)]
+ set_default: bool,
+ /// Additional public key to add to the identity; may be given multiple
+ /// times
+ #[clap(short, long, value_parser)]
+ public: Vec<Key<'static>>,
+ /// Threshold of keys required to sign the next revision
+ #[clap(long, value_parser)]
+ threshold: Option<NonZeroUsize>,
+ /// Alternate location where the identity history is published to; may be
+ /// given multiple times
+ #[clap(
+ long = "mirror",
+ value_parser,
+ value_name = "URL",
+ value_hint = ValueHint::Url,
+ )]
+ mirrors: Vec<Url>,
+ /// Optional date/time after which the current revision of the identity
+ /// should no longer be considered valid
+ #[clap(long, value_parser, value_name = "DATETIME")]
+ expires: Option<DateTime>,
+ /// Custom data
+ ///
+ /// The data must be parseable as canonical JSON, ie. not contain any
+ /// floating point values.
+ #[clap(
+ long,
+ value_parser,
+ value_name = "FILE",
+ value_hint = ValueHint::FilePath,
+ )]
+ custom: Option<PathBuf>,
+ /// Stop for editing the metadata in $EDITOR
+ #[clap(long, value_parser)]
+ edit: bool,
+ /// Don't commit anything to disk
+ #[clap(long, value_parser)]
+ dry_run: bool,
+}
+
+#[derive(serde::Serialize)]
+pub struct Output {
+ #[serde(skip_serializing_if = "Option::is_none")]
+ committed: Option<Committed>,
+ data: metadata::Signed<metadata::Metadata<'static>>,
+}
+
+#[derive(serde::Serialize)]
+pub struct Committed {
+ repo: PathBuf,
+ #[serde(rename = "ref")]
+ refname: Refname,
+ #[serde(with = "crate::git::serde::oid")]
+ commit: git2::Oid,
+}
+
+pub fn init(args: Init) -> cmd::Result<Output> {
+ let git_dir = args.git_dir;
+ info!("Initialising fresh identity at {}", git_dir.display());
+
+ let custom = args.custom.map(json::load).transpose()?.unwrap_or_default();
+ let cfg = git2::Config::open_default()?;
+ let mut signer = cfg::signer(&cfg, ui::askpass)?;
+ let threshold = match args.threshold {
+ None => NonZeroUsize::new(1)
+ .unwrap()
+ .saturating_add(args.public.len() / 2),
+ Some(t) => {
+ ensure!(
+ t.get() < args.public.len(),
+ "threshold must be smaller than the number of keys"
+ );
+ t
+ },
+ };
+
+ let signer_id = signer.ident().to_owned();
+ let keys = iter::once(signer_id.clone())
+ .map(metadata::Key::from)
+ .chain(args.public)
+ .collect::<KeySet>();
+
+ let meta = {
+ let id = metadata::Identity {
+ spec_version: crate::SPEC_VERSION,
+ prev: None,
+ keys,
+ threshold,
+ mirrors: args.mirrors.into_iter().collect(),
+ expires: args.expires,
+ custom,
+ };
+
+ if args.edit {
+ edit_metadata(Editable::from(id))?.try_into()?
+ } else {
+ id
+ }
+ };
+ let sigid = metadata::IdentityId::try_from(&meta).unwrap();
+ let signed = metadata::Metadata::identity(meta).sign(iter::once(&mut signer))?;
+
+ let out = if !args.dry_run {
+ let id_ref = Refname::try_from(format!("refs/heads/it/ids/{}", sigid)).unwrap();
+ let repo = git::repo::open_or_init(
+ git_dir,
+ git::repo::InitOpts {
+ bare: !args.no_bare,
+ description: "`it` keyring",
+ initial_head: &id_ref,
+ },
+ )?;
+
+ let mut tx = refs::Transaction::new(&repo)?;
+ let id_ref = tx.lock_ref(id_ref)?;
+ ensure!(
+ if_not_found_none(repo.refname_to_id(id_ref.name()))?.is_none(),
+ "{id_ref} already exists",
+ );
+
+ let blob = json::to_blob(&repo, &signed)?;
+ let tree = {
+ let mut bld = repo.treebuilder(None)?;
+ bld.insert(META_FILE_ID, blob, git2::FileMode::Blob.into())?;
+ let oid = bld.write()?;
+ repo.find_tree(oid)?
+ };
+ let msg = format!("Create identity {}", sigid);
+ let oid = git::commit_signed(&mut signer, &repo, msg, &tree, &[])?;
+ id_ref.set_target(oid, "it: create");
+
+ let mut cfg = repo.config()?;
+ cfg.set_str(
+ cfg::git::USER_SIGNING_KEY,
+ &format!("key::{}", signer_id.to_openssh()?),
+ )?;
+ let idstr = sigid.to_string();
+ cfg.set_str(cfg::git::IT_ID, &idstr)?;
+ if args.set_default {
+ cfg.open_global()?.set_str(cfg::git::IT_ID, &idstr)?;
+ }
+
+ tx.commit()?;
+ if !repo.is_bare() {
+ repo.checkout_head(None).ok();
+ }
+
+ Output {
+ committed: Some(Committed {
+ repo: repo.path().to_owned(),
+ refname: id_ref.into(),
+ commit: oid,
+ }),
+ data: signed,
+ }
+ } else {
+ Output {
+ committed: None,
+ data: signed,
+ }
+ };
+
+ Ok(out)
+}
diff --git a/src/cmd/id/show.rs b/src/cmd/id/show.rs
new file mode 100644
index 0000000..4a25455
--- /dev/null
+++ b/src/cmd/id/show.rs
@@ -0,0 +1,75 @@
+// Copyright © 2022 Kim Altintop <kim@eagain.io>
+// SPDX-License-Identifier: GPL-2.0-only WITH openvpn-openssl-exception
+
+use std::path::PathBuf;
+
+use super::Common;
+use crate::{
+ cmd::{
+ self,
+ args::Refname,
+ FromGit as _,
+ GitIdentity,
+ },
+ metadata::{
+ self,
+ ContentHash,
+ },
+};
+
+#[derive(Debug, clap::Args)]
+pub struct Show {
+ #[clap(flatten)]
+ common: Common,
+ /// Blob hash to show
+ ///
+ /// Instead of looking for an id.json in the tree --ref points to, load a
+ /// particular id.json by hash. If given, --ref is ignored.
+ #[clap(long = "hash", value_parser, value_name = "OID")]
+ blob_hash: Option<git2::Oid>,
+}
+
+#[derive(serde::Serialize)]
+pub struct Output {
+ repo: PathBuf,
+ #[serde(rename = "ref")]
+ refname: Refname,
+ hash: ContentHash,
+ status: Status,
+ data: metadata::Signed<metadata::Identity>,
+}
+
+#[derive(serde::Serialize)]
+#[serde(rename_all = "UPPERCASE")]
+pub enum Status {
+ Verified {
+ id: metadata::IdentityId,
+ },
+ #[serde(with = "crate::serde::display")]
+ Invalid(metadata::error::Verification),
+}
+
+impl From<Result<metadata::IdentityId, metadata::error::Verification>> for Status {
+ fn from(r: Result<metadata::IdentityId, metadata::error::Verification>) -> Self {
+ r.map(|id| Self::Verified { id })
+ .unwrap_or_else(Self::Invalid)
+ }
+}
+
+pub fn show(args: Show) -> cmd::Result<Output> {
+ let (repo, refname) = args.common.resolve()?;
+
+ let GitIdentity { hash, signed } = match args.blob_hash {
+ None => metadata::Identity::from_tip(&repo, &refname)?,
+ Some(oid) => metadata::Identity::from_blob(&repo.find_blob(oid)?)?,
+ };
+ let status = signed.verify(cmd::find_parent(&repo)).into();
+
+ Ok(Output {
+ repo: repo.path().to_owned(),
+ refname,
+ hash,
+ status,
+ data: signed,
+ })
+}
diff --git a/src/cmd/id/sign.rs b/src/cmd/id/sign.rs
new file mode 100644
index 0000000..b63ef94
--- /dev/null
+++ b/src/cmd/id/sign.rs
@@ -0,0 +1,221 @@
+// Copyright © 2022 Kim Altintop <kim@eagain.io>
+// SPDX-License-Identifier: GPL-2.0-only WITH openvpn-openssl-exception
+
+use std::collections::BTreeMap;
+
+use anyhow::{
+ anyhow,
+ bail,
+ ensure,
+ Context as _,
+};
+
+use super::{
+ edit,
+ Common,
+};
+use crate::{
+ cfg,
+ cmd::{
+ self,
+ args::Refname,
+ id::META_FILE_ID,
+ ui::{
+ self,
+ edit_commit_message,
+ info,
+ },
+ FromGit as _,
+ GitIdentity,
+ },
+ git::{
+ self,
+ if_not_found_none,
+ refs,
+ },
+ metadata,
+};
+
+#[derive(Debug, clap::Args)]
+pub struct Sign {
+ #[clap(flatten)]
+ common: Common,
+ /// Commit to this branch if the signature threshold is met
+ #[clap(short = 'b', long, value_parser, value_name = "REF")]
+ commit_to: Refname,
+ /// Check out the committed changes
+ ///
+ /// Only has an effect if the repository is non-bare.
+ #[clap(long, value_parser)]
+ checkout: bool,
+ /// Don't commit anything to disk
+ #[clap(long, value_parser)]
+ dry_run: bool,
+ /// Commit message for this edit
+ ///
+ /// Like git, $EDITOR will be invoked if not specified.
+ #[clap(short, long, value_parser)]
+ message: Option<String>,
+}
+
+#[derive(serde::Serialize)]
+pub struct Output {
+ #[serde(rename = "ref")]
+ refname: Refname,
+ #[serde(with = "crate::git::serde::oid")]
+ commit: git2::Oid,
+}
+
+pub fn sign(args: Sign) -> cmd::Result<Output> {
+ let (repo, refname) = args.common.resolve()?;
+ let mut tx = refs::Transaction::new(&repo)?;
+ let _tip = tx.lock_ref(refname.clone())?;
+
+ let GitIdentity {
+ signed:
+ metadata::Signed {
+ signed: proposed,
+ signatures: proposed_signatures,
+ },
+ ..
+ } = metadata::Identity::from_tip(&repo, &refname)?;
+ let prev_hash: git2::Oid = proposed
+ .prev
+ .as_ref()
+ .ok_or_else(|| anyhow!("cannot sign a genesis revision"))?
+ .into();
+ let (parent, target_ref) = if refname == args.commit_to {
+ // Signing in-place is only legal if the proposed update already
+ // meets the signature threshold
+ let _ = proposed
+ .verify(&proposed_signatures, cmd::find_parent(&repo))
+ .context("proposed update does not meet the signature threshold")?;
+ (proposed.clone(), repo.find_reference(&args.commit_to)?)
+ } else {
+ let target_ref = if_not_found_none(repo.find_reference(&args.commit_to))?;
+ match target_ref {
+ // If the target ref exists, it must yield a verified id.json whose
+ // blob hash equals the 'prev' hash of the proposed update
+ Some(tgt) => {
+ let parent_commit = tgt.peel_to_commit()?;
+ let GitIdentity {
+ hash: parent_hash,
+ signed:
+ metadata::Signed {
+ signed: parent,
+ signatures: parent_signatures,
+ },
+ } = metadata::Identity::from_commit(&repo, &parent_commit).with_context(|| {
+ format!("failed to load {} from {}", META_FILE_ID, &args.commit_to)
+ })?;
+ let _ = parent
+ .verify(&parent_signatures, cmd::find_parent(&repo))
+ .with_context(|| format!("target {} could not be verified", &args.commit_to))?;
+ ensure!(
+ parent_hash == prev_hash,
+ "parent hash (.prev) doesn't match"
+ );
+
+ (parent, tgt)
+ },
+
+ // If the target ref is unborn, the proposed's parent commit must
+ // yield a verified id.json, as we will create the target from
+ // HEAD^1
+ None => {
+ let parent_commit = repo
+ .find_reference(&refname)?
+ .peel_to_commit()?
+ .parents()
+ .next()
+ .ok_or_else(|| anyhow!("cannot sign an initial commit"))?;
+ let GitIdentity {
+ hash: parent_hash,
+ signed:
+ metadata::Signed {
+ signed: parent,
+ signatures: parent_signatures,
+ },
+ } = metadata::Identity::from_commit(&repo, &parent_commit)?;
+ let _ = parent
+ .verify(&parent_signatures, cmd::find_parent(&repo))
+ .with_context(|| {
+ format!(
+ "parent commit {} of {} could not be verified",
+ parent_commit.id(),
+ refname
+ )
+ })?;
+ ensure!(
+ parent_hash == prev_hash,
+ "parent hash (.prev) doesn't match"
+ );
+
+ let tgt = repo.reference(
+ &args.commit_to,
+ parent_commit.id(),
+ false,
+ &format!("branch: Created from {}^1", refname),
+ )?;
+
+ (parent, tgt)
+ },
+ }
+ };
+ let commit_to = tx.lock_ref(args.commit_to)?;
+
+ let canonical = proposed.canonicalise()?;
+ let mut signer = cfg::signer(&repo.config()?, ui::askpass)?;
+ let mut signatures = BTreeMap::new();
+ let keyid = metadata::KeyId::from(signer.ident());
+ if !parent.keys.contains_key(&keyid) && !proposed.keys.contains_key(&keyid) {
+ bail!("key {} is not eligible to sign the document", keyid);
+ }
+ if proposed_signatures.contains_key(&keyid) {
+ bail!("proposed update is already signed with key {}", keyid);
+ }
+
+ let signature = signer.sign(&canonical)?;
+ signatures.insert(keyid, metadata::Signature::from(signature));
+ signatures.extend(proposed_signatures);
+
+ let _ = proposed
+ .verify(&signatures, cmd::find_parent(&repo))
+ .context("proposal could not be verified after signing")?;
+
+ let signed = metadata::Signed {
+ signed: metadata::Metadata::identity(proposed),
+ signatures,
+ };
+
+ let parent_commit = target_ref.peel_to_commit()?;
+ let parent_tree = parent_commit.tree()?;
+ let on_head = !repo.is_bare() && git2::Branch::wrap(target_ref).is_head();
+
+ let tree = if on_head {
+ edit::write_tree(&repo, &signed)
+ } else {
+ edit::write_tree_bare(&repo, &signed, Some(&parent_tree))
+ }?;
+ let msg = args
+ .message
+ .map(Ok)
+ .unwrap_or_else(|| edit_commit_message(&repo, commit_to.name(), &parent_tree, &tree))?;
+ let commit = git::commit_signed(&mut signer, &repo, msg, &tree, &[&parent_commit])?;
+ commit_to.set_target(commit, "it: identity signoff");
+
+ tx.commit()?;
+
+ if on_head {
+ repo.checkout_tree(
+ tree.as_object(),
+ Some(git2::build::CheckoutBuilder::new().safe()),
+ )?;
+ info!("Checked out tree {}", tree.id());
+ }
+
+ Ok(Output {
+ refname: commit_to.into(),
+ commit,
+ })
+}