From d2f423521ec76406944ad83098ec33afe20c692b Mon Sep 17 00:00:00 2001 From: Kim Altintop Date: Mon, 9 Jan 2023 13:18:33 +0100 Subject: This is it Squashed commit of all the exploration history. Development starts here. Signed-off-by: Kim Altintop --- src/cmd/id/edit.rs | 209 ++++++++++++++++++++++++++++++++++++++++++++++++ src/cmd/id/init.rs | 230 +++++++++++++++++++++++++++++++++++++++++++++++++++++ src/cmd/id/show.rs | 75 +++++++++++++++++ src/cmd/id/sign.rs | 221 ++++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 735 insertions(+) create mode 100644 src/cmd/id/edit.rs create mode 100644 src/cmd/id/init.rs create mode 100644 src/cmd/id/show.rs create mode 100644 src/cmd/id/sign.rs (limited to 'src/cmd/id') 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 +// 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, + /// 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, +} + +#[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 { + 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, +) -> crate::Result> { + 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, + from: Option<&git2::Tree>, +) -> crate::Result> { + 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 +// 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>, + /// Threshold of keys required to sign the next revision + #[clap(long, value_parser)] + threshold: Option, + /// 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, + /// 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, + /// 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, + /// 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, + data: metadata::Signed>, +} + +#[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 { + 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::(); + + 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 +// 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, +} + +#[derive(serde::Serialize)] +pub struct Output { + repo: PathBuf, + #[serde(rename = "ref")] + refname: Refname, + hash: ContentHash, + status: Status, + data: metadata::Signed, +} + +#[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> for Status { + fn from(r: Result) -> Self { + r.map(|id| Self::Verified { id }) + .unwrap_or_else(Self::Invalid) + } +} + +pub fn show(args: Show) -> cmd::Result { + 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 +// 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, +} + +#[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 { + 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, + }) +} -- cgit v1.2.3