diff --git a/cli/src/config-schema.json b/cli/src/config-schema.json index 14e71edf97b..a6f980c6f41 100644 --- a/cli/src/config-schema.json +++ b/cli/src/config-schema.json @@ -355,6 +355,31 @@ "default": "1MiB" } } + }, + "signing": { + "type": "object", + "description": "Settings for verifying and creating cryptographic commit signatures", + "properties": { + "backend": { + "type": "string", + "description": "Which backend to use to create commit signatures" + }, + "key": { + "type": "string", + "description": "The key parameter to pass to the signing backend. Overridden by `jj sign` parameter or by the global `--sign-with` option" + }, + "sign-all": { + "type": "boolean", + "description": "Whether to sign all commits by default. Overridden by global `--no-sign` option", + "default": false + }, + "backends": { + "type": "object", + "description": "Tables of options to pass to specific signing backends", + "properties": {}, + "additionalProperties": true + } + } } } } \ No newline at end of file diff --git a/lib/src/backend.rs b/lib/src/backend.rs index 50980a96313..3fc5391819e 100644 --- a/lib/src/backend.rs +++ b/lib/src/backend.rs @@ -27,6 +27,7 @@ use thiserror::Error; use crate::content_hash::ContentHash; use crate::merge::Merge; use crate::repo_path::{RepoPath, RepoPathComponent, RepoPathComponentBuf}; +use crate::signing::SignError; pub trait ObjectId { fn new(value: Vec) -> Self; @@ -147,7 +148,7 @@ content_hash! { } } -pub type SigningFn = Box BackendResult>>; +pub type SigningFn = Box Result, SignError>>; /// Identifies a single legacy tree, which may have path-level conflicts, or a /// merge of multiple trees, where the individual trees do not have conflicts. diff --git a/lib/src/commit.rs b/lib/src/commit.rs index b5c250e8588..0010ed0ea6a 100644 --- a/lib/src/commit.rs +++ b/lib/src/commit.rs @@ -22,6 +22,7 @@ use std::sync::Arc; use crate::backend; use crate::backend::{BackendError, ChangeId, CommitId, MergedTreeId, Signature}; use crate::merged_tree::MergedTree; +use crate::signing::Verification; use crate::store::Store; #[derive(Clone)] @@ -146,6 +147,19 @@ impl Commit { } false } + + /// A quick way to just check if a signature is present. + pub fn is_signed(&self) -> bool { + self.data.secure_sig.is_some() + } + + /// A slow (but cached) way to get the full verification. + pub fn verification(&self) -> Option { + self.data + .secure_sig + .as_ref() + .map(|sig| self.store.signer().verify(&self.id, &sig.data, &sig.sig)) + } } /// Wrapper to sort `Commit` by committer timestamp. diff --git a/lib/src/commit_builder.rs b/lib/src/commit_builder.rs index 6bb10270521..9f816e29e49 100644 --- a/lib/src/commit_builder.rs +++ b/lib/src/commit_builder.rs @@ -16,10 +16,11 @@ use std::sync::Arc; -use crate::backend::{self, BackendResult, ChangeId, CommitId, MergedTreeId, Signature}; +use crate::backend::{self, BackendResult, ChangeId, CommitId, MergedTreeId, Signature, SigningFn}; use crate::commit::Commit; use crate::repo::{MutableRepo, Repo}; -use crate::settings::{JJRng, UserSettings}; +use crate::settings::{JJRng, SignSettings, UserSettings}; +use crate::signing::SignBehavior; #[must_use] pub struct CommitBuilder<'repo> { @@ -27,6 +28,7 @@ pub struct CommitBuilder<'repo> { rng: Arc, commit: backend::Commit, rewrite_source: Option, + sign_settings: SignSettings, } impl CommitBuilder<'_> { @@ -55,6 +57,7 @@ impl CommitBuilder<'_> { rng, commit, rewrite_source: None, + sign_settings: settings.sign_settings(), } } @@ -83,6 +86,7 @@ impl CommitBuilder<'_> { commit, rng: settings.get_rng(), rewrite_source: Some(predecessor.clone()), + sign_settings: settings.sign_settings(), } } @@ -157,14 +161,44 @@ impl CommitBuilder<'_> { self } - pub fn write(self) -> BackendResult { + pub fn sign_settings(&self) -> &SignSettings { + &self.sign_settings + } + + pub fn set_sign_behavior(mut self, sign_behavior: SignBehavior) -> Self { + self.sign_settings.behavior = sign_behavior; + self + } + + pub fn set_sign_key(mut self, sign_key: Option) -> Self { + self.sign_settings.key = sign_key; + self + } + + pub fn write(mut self) -> BackendResult { let mut rewrite_source_id = None; if let Some(rewrite_source) = self.rewrite_source { if *rewrite_source.change_id() == self.commit.change_id { rewrite_source_id.replace(rewrite_source.id().clone()); } } - let commit = self.mut_repo.write_commit(self.commit)?; + + let sign_settings = self.sign_settings; + let store = self.mut_repo.store(); + + let signing_fn = (store.signer().can_sign() && sign_settings.should_sign(&self.commit)) + .then(|| { + let store = store.clone(); + Box::new(move |data: &_| store.signer().sign(data, sign_settings.key.as_deref())) + as SigningFn + }); + + // Commit backend doesn't use secure_sig for writing and enforces it with an + // assert, but sign_settings.should_sign check above will want to know + // if we're rewriting a signed commit + self.commit.secure_sig = None; + + let commit = self.mut_repo.write_commit(self.commit, signing_fn)?; if let Some(rewrite_source_id) = rewrite_source_id { self.mut_repo .record_rewritten_commit(rewrite_source_id, commit.id().clone()) diff --git a/lib/src/git_backend.rs b/lib/src/git_backend.rs index 740a5cb04ee..3ba6a281ddd 100644 --- a/lib/src/git_backend.rs +++ b/lib/src/git_backend.rs @@ -967,7 +967,10 @@ impl Backend for GitBackend { let mut data = Vec::with_capacity(512); commit.write_to(&mut data).unwrap(); - let sig = sign(&data)?; + let sig = sign(&data).map_err(|err| BackendError::WriteObject { + object_type: "commit", + source: Box::new(err), + })?; commit .extra_headers .push(("gpgsig".into(), sig.clone().into())); diff --git a/lib/src/lib.rs b/lib/src/lib.rs index f0ac9314a4c..cf649466e5b 100644 --- a/lib/src/lib.rs +++ b/lib/src/lib.rs @@ -58,6 +58,7 @@ pub mod revset; pub mod revset_graph; pub mod rewrite; pub mod settings; +pub mod signing; pub mod simple_op_heads_store; pub mod simple_op_store; pub mod stacked_table; diff --git a/lib/src/local_backend.rs b/lib/src/local_backend.rs index a54bee2dd02..bcacab86aab 100644 --- a/lib/src/local_backend.rs +++ b/lib/src/local_backend.rs @@ -281,7 +281,7 @@ impl Backend for LocalBackend { let mut proto = commit_to_proto(&commit); if let Some(mut sign) = sign_with { let data = proto.encode_to_vec(); - let sig = sign(&data)?; + let sig = sign(&data).map_err(to_other_err)?; proto.secure_sig = Some(sig.clone()); commit.secure_sig = Some(SecureSig { data, sig }); } diff --git a/lib/src/repo.rs b/lib/src/repo.rs index ac26994d015..f3ab4e28496 100644 --- a/lib/src/repo.rs +++ b/lib/src/repo.rs @@ -31,7 +31,7 @@ use tracing::instrument; use self::dirty_cell::DirtyCell; use crate::backend::{ Backend, BackendError, BackendInitError, BackendLoadError, BackendResult, ChangeId, CommitId, - MergedTreeId, ObjectId, + MergedTreeId, ObjectId, SigningFn, }; use crate::commit::{Commit, CommitByCommitterTimestamp}; use crate::commit_builder::CommitBuilder; @@ -52,6 +52,7 @@ use crate::refs::{ use crate::revset::{self, ChangeIdIndex, Revset, RevsetExpression}; use crate::rewrite::{DescendantRebaser, RebaseOptions}; use crate::settings::{RepoSettings, UserSettings}; +use crate::signing::Signer; use crate::simple_op_heads_store::SimpleOpHeadsStore; use crate::simple_op_store::SimpleOpStore; use crate::store::Store; @@ -156,7 +157,8 @@ impl ReadonlyRepo { let backend = backend_initializer(user_settings, &store_path)?; let backend_path = store_path.join("type"); fs::write(&backend_path, backend.name()).context(&backend_path)?; - let store = Store::new(backend, user_settings.use_tree_conflict_format()); + let signer = Signer::from_settings(user_settings); + let store = Store::new(backend, signer, user_settings.use_tree_conflict_format()); let repo_settings = user_settings.with_repo(&repo_path).unwrap(); let op_store_path = repo_path.join("op_store"); @@ -608,6 +610,7 @@ impl RepoLoader { ) -> Result { let store = Store::new( store_factories.load_backend(user_settings, &repo_path.join("store"))?, + Signer::from_settings(user_settings), user_settings.use_tree_conflict_format(), ); let repo_settings = user_settings.with_repo(repo_path).unwrap(); @@ -792,8 +795,12 @@ impl MutableRepo { CommitBuilder::for_rewrite_from(self, settings, predecessor) } - pub fn write_commit(&mut self, commit: backend::Commit) -> BackendResult { - let commit = self.store().write_commit(commit)?; + pub fn write_commit( + &mut self, + commit: backend::Commit, + sign_with: Option, + ) -> BackendResult { + let commit = self.store().write_commit(commit, sign_with)?; self.add_head(&commit); Ok(commit) } diff --git a/lib/src/settings.rs b/lib/src/settings.rs index a56bc83f9ae..80213f620d5 100644 --- a/lib/src/settings.rs +++ b/lib/src/settings.rs @@ -21,9 +21,10 @@ use chrono::DateTime; use rand::prelude::*; use rand_chacha::ChaCha20Rng; -use crate::backend::{ChangeId, ObjectId, Signature, Timestamp}; +use crate::backend::{ChangeId, Commit, ObjectId, Signature, Timestamp}; use crate::fmt_util::binary_prefix; use crate::fsmonitor::FsmonitorKind; +use crate::signing::SignBehavior; #[derive(Debug, Clone)] pub struct UserSettings { @@ -63,6 +64,39 @@ impl Default for GitSettings { } } +#[derive(Debug, Clone, Default)] +pub struct SignSettings { + pub behavior: SignBehavior, + pub user_email: String, + pub key: Option, +} + +impl SignSettings { + pub fn from_settings(settings: &UserSettings) -> Self { + Self { + behavior: SignBehavior::from_config( + settings + .config() + .get_bool("signing.sign-all") + .unwrap_or(false), + ), + user_email: settings.user_email(), + key: settings.config().get_string("signing.key").ok(), + } + } + + pub fn should_sign(&self, commit: &Commit) -> bool { + match self.behavior { + SignBehavior::Drop => false, + SignBehavior::Keep => { + commit.secure_sig.is_some() && commit.author.email == self.user_email + } + SignBehavior::Own => commit.author.email == self.user_email, + SignBehavior::Force => true, + } + } +} + fn get_timestamp_config(config: &config::Config, key: &str) -> Option { match config.get_string(key) { Ok(timestamp_str) => match DateTime::parse_from_rfc3339(×tamp_str) { @@ -215,6 +249,16 @@ impl UserSettings { e @ Err(_) => e, } } + + // separate from sign_settings as those two are needed in pretty different + // places + pub fn signing_backend(&self) -> Option { + self.config.get_string("signing.backend").ok() + } + + pub fn sign_settings(&self) -> SignSettings { + SignSettings::from_settings(self) + } } /// This Rng uses interior mutability to allow generating random values using an diff --git a/lib/src/signing.rs b/lib/src/signing.rs new file mode 100644 index 00000000000..e456781f346 --- /dev/null +++ b/lib/src/signing.rs @@ -0,0 +1,217 @@ +// Copyright 2023 The Jujutsu Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#![allow(missing_docs)] + +use std::collections::HashMap; +use std::fmt::Debug; +use std::sync::RwLock; + +use thiserror::Error; + +use crate::backend::CommitId; +use crate::settings::UserSettings; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SigStatus { + /// Valid signature that matches the data. + Good, + /// Valid signature that could not be verified (e.g. due to an unknown key). + Unknown, + /// Valid signature that does not match the signed data. + Bad, + /// Invalid signature. + Invalid, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Verification { + /// The status of the signature. + pub status: SigStatus, + /// The key representation, if available. For GPG, this will be the key + /// fingerprint. + pub key: Option, + /// A display string, if available. For GPG, this will be formatted primary + /// user ID. + pub display: Option, +} + +impl Verification { + pub fn unknown() -> Self { + Self { + status: SigStatus::Unknown, + key: None, + display: None, + } + } + pub fn invalid() -> Self { + Self { + status: SigStatus::Invalid, + key: None, + display: None, + } + } +} + +/// The backend for signing and verifying cryptographic signatures. +/// +/// This allows using different signers, such as GPG or SSH, or different +/// versions of them. +pub trait SigningBackend: Debug + Send + Sync { + /// The name of the backend, used for selecting the main backend in config + /// and in CLI. + fn name(&self) -> &str; + + /// Check if the signature was created by this backend implementation. + /// + /// Should check the signature format, usually just looks at the prefix. + fn is_of_this_type(&self, signature: &[u8]) -> bool; + + /// Create a signature for arbitrary data. + /// + /// The `key` parameter is what `jj sign` receives as key argument, or what + /// is configured in the `signing.key` config. + fn sign(&self, data: &[u8], key: Option<&str>) -> Result, SignError>; + + /// Verify a signature. Should be reflexive with `sign`: + /// ```rust,ignore + /// verify(data, sign(data)?)?.status == SigStatus::Good + /// ``` + fn verify(&self, data: &[u8], signature: &[u8]) -> Result; +} + +#[derive(Debug, Error)] +pub enum SignError { + #[error("Would override a foreign commit signature")] + CannotRewrite, + #[error("Signing error: {0}")] + Other(#[from] Box), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum SignBehavior { + /// Drop existing signatures. + /// This is what jj did before signing support or does now when a signing + /// backend is not configured. + #[default] + Drop, + /// Only sign commits that were authored by self and already signed, + /// "preserving" the signature across rewrites. + /// This is what jj does when a signing backend is configured. + Keep, + /// Sign/re-sign commits that were authored by self. Foreign signatures are + /// overwritten on authored commits and dropped on others. + /// This is what jj does when configured to always sign. + Own, + /// Always sign commits, regardless of who authored or signed them before. + /// This is what jj does on `jj sign -f` or `jj --force-sign `. + Force, +} + +impl SignBehavior { + pub fn from_config(sign_all: bool) -> Self { + match sign_all { + true => SignBehavior::Own, + false => SignBehavior::Keep, + } + } +} + +/// Wraps low-level signing backends and adds caching, similar to `Store`. +#[derive(Debug, Default)] +pub struct Signer { + main_backend: Option>, + other_backends: Vec>, + cache: RwLock>, +} + +impl Signer { + pub fn from_settings(settings: &UserSettings) -> Self { + let mut other_backends: Vec> = vec![ + // todo implement some backends + ]; + + let main_backend = settings + .signing_backend() + .and_then(|backend| other_backends.iter().position(|b| b.name() == backend)) + .map(|pos| other_backends.swap_remove(pos)); + + Self::new(main_backend, other_backends) + } + + pub fn new( + main_backend: Option>, + other_backends: Vec>, + ) -> Self { + Self { + main_backend, + other_backends, + cache: Default::default(), + } + } + + pub fn can_sign(&self) -> bool { + self.main_backend.is_some() + } + + /// This is just a pass-through to the main backend that unconditionally + /// creates a signature. + pub fn sign(&self, data: &[u8], key: Option<&str>) -> Result, SignError> { + self.main_backend + .as_ref() + .expect("tried to sign without checking can_sign first") + .sign(data, key) + } + + /// Looks for backend that can verify the signature and returns the result + /// of its verification. + pub fn verify(&self, commit_id: &CommitId, data: &[u8], signature: &[u8]) -> Verification { + let cached = self.cache.read().unwrap().get(commit_id).cloned(); + if let Some(check) = cached { + return check; + } + if let Some(backend) = self + .main_backend + .iter() + .chain(self.other_backends.iter()) + .find(|b| b.is_of_this_type(signature)) + { + let Ok(check) = backend.verify(data, signature) else { + return Verification::unknown(); + }; + + // a key might get imported before next call?. + // realistically this is unlikely, but technically + // it's correct to not cache unknowns here + if check.status != SigStatus::Unknown { + self.cache + .write() + .unwrap() + .insert(commit_id.clone(), check.clone()); + } + check + } else { + // now here it's correct to cache unknowns, as we don't + // have a backend that knows how to handle this signature + // + // not sure about how much of an optimization this is + self.cache + .write() + .unwrap() + .insert(commit_id.clone(), Verification::unknown()); + Verification::unknown() + } + } +} diff --git a/lib/src/store.rs b/lib/src/store.rs index 69d1f73dd60..c2629da93d8 100644 --- a/lib/src/store.rs +++ b/lib/src/store.rs @@ -22,14 +22,15 @@ use std::sync::{Arc, RwLock}; use pollster::FutureExt; -use crate::backend; use crate::backend::{ - Backend, BackendResult, ChangeId, CommitId, ConflictId, FileId, MergedTreeId, SymlinkId, TreeId, + self, Backend, BackendResult, ChangeId, CommitId, ConflictId, FileId, MergedTreeId, SigningFn, + SymlinkId, TreeId, }; use crate::commit::Commit; use crate::merge::{Merge, MergedTreeValue}; use crate::merged_tree::MergedTree; use crate::repo_path::RepoPath; +use crate::signing::Signer; use crate::tree::Tree; use crate::tree_builder::TreeBuilder; @@ -37,6 +38,7 @@ use crate::tree_builder::TreeBuilder; /// adds caching. pub struct Store { backend: Box, + signer: Signer, commit_cache: RwLock>>, tree_cache: RwLock>>, use_tree_conflict_format: bool, @@ -51,9 +53,14 @@ impl Debug for Store { } impl Store { - pub fn new(backend: Box, use_tree_conflict_format: bool) -> Arc { + pub fn new( + backend: Box, + signer: Signer, + use_tree_conflict_format: bool, + ) -> Arc { Arc::new(Store { backend, + signer, commit_cache: Default::default(), tree_cache: Default::default(), use_tree_conflict_format, @@ -64,6 +71,10 @@ impl Store { self.backend.as_any() } + pub fn signer(&self) -> &Signer { + &self.signer + } + /// Whether new tree should be written using the tree-level format. pub fn use_tree_conflict_format(&self) -> bool { self.use_tree_conflict_format @@ -124,9 +135,14 @@ impl Store { Ok(data) } - pub fn write_commit(self: &Arc, commit: backend::Commit) -> BackendResult { + pub fn write_commit( + self: &Arc, + commit: backend::Commit, + sign_with: Option, + ) -> BackendResult { assert!(!commit.parents.is_empty()); - let (commit_id, commit) = self.backend.write_commit(commit, None)?; + + let (commit_id, commit) = self.backend.write_commit(commit, sign_with)?; let data = Arc::new(commit); { let mut write_locked_cache = self.commit_cache.write().unwrap(); diff --git a/lib/testutils/src/lib.rs b/lib/testutils/src/lib.rs index b7438b4cd92..ce69bd80496 100644 --- a/lib/testutils/src/lib.rs +++ b/lib/testutils/src/lib.rs @@ -336,7 +336,7 @@ pub fn commit_with_tree(store: &Arc, tree_id: MergedTreeId) -> Commit { committer: signature, secure_sig: None, }; - store.write_commit(commit).unwrap() + store.write_commit(commit, None).unwrap() } pub fn dump_tree(store: &Arc, tree_id: &MergedTreeId) -> String { diff --git a/lib/testutils/src/test_backend.rs b/lib/testutils/src/test_backend.rs index f4e1c62a1e9..aa2d2d5d55e 100644 --- a/lib/testutils/src/test_backend.rs +++ b/lib/testutils/src/test_backend.rs @@ -282,7 +282,7 @@ impl Backend for TestBackend { if let Some(sign) = &mut sign_with { let data = format!("{contents:?}").into_bytes(); - let sig = sign(&data)?; + let sig = sign(&data).map_err(|err| BackendError::Other(Box::new(err)))?; contents.secure_sig = Some(SecureSig { data, sig }); }