pub mod sigrefs; #[cfg(any(test, feature = "test"))] pub mod arbitrary; use std::collections::BTreeMap; use std::fmt::Debug; use std::io; use std::io::{BufRead, BufReader}; use std::ops::Deref; use std::str::FromStr; use crypto::signature; use crypto::{PublicKey, Signature}; use radicle_core::NodeId; use serde::{Deserialize, Serialize}; use thiserror::Error; use crate::git; use crate::git::Oid; use crate::git::raw::ErrorExt as _; use crate::storage; use crate::storage::RemoteId; use crate::storage::refs::sigrefs::read::Tip; pub use crate::git::refs::storage::*; use super::HasRepoId; /// File in which the signed references are stored, in the `refs/rad/sigrefs` branch. pub const REFS_BLOB_PATH: &str = "refs"; /// File in which the signature over the references is stored in the `refs/rad/sigrefs` branch. pub const SIGNATURE_BLOB_PATH: &str = "signature"; #[derive(Debug, Error)] pub enum Error { #[error("invalid reference")] InvalidRef, #[error("invalid reference: {0}")] Ref(#[from] git::RefError), #[error(transparent)] Git(#[from] git::raw::Error), #[error(transparent)] Read(#[from] sigrefs::read::error::Read), #[error(transparent)] Write(#[from] sigrefs::write::error::Write), } impl Error { /// Whether this error is caused by a reference not being found. pub fn is_not_found(&self) -> bool { match self { Self::Git(e) => e.is_not_found(), Self::Read(sigrefs::read::error::Read::MissingSigrefs { .. }) => true, _ => false, } } } // TODO(finto): we should turn `git::fmt::RefString` to `git::fmt::Qualified`, // since all these refs SHOULD be `Qualified`. /// The published state of a local repository. #[derive(Default, Clone, Debug, PartialEq, Eq, Serialize)] pub struct Refs(BTreeMap); impl Refs { pub fn new() -> Self { Self(BTreeMap::new()) } /// Save the signed refs to disk. /// This creates a new commit on the signed refs branch, and updates the branch pointer. pub fn save( self, namespace: NodeId, committer: sigrefs::git::Committer, repo: &R, signer: &S, ) -> Result where R: sigrefs::git::object::Reader + sigrefs::git::object::Writer, R: sigrefs::git::reference::Reader + sigrefs::git::reference::Writer, R: HasRepoId, S: signature::Signer, S: signature::Verifier, { self.save_with(namespace, committer, repo, signer, false) } /// Save the signed refs to disk, even if the refs are unchanged. pub fn force_save( self, namespace: NodeId, committer: sigrefs::git::Committer, repo: &R, signer: &S, ) -> Result where R: sigrefs::git::object::Reader + sigrefs::git::object::Writer, R: sigrefs::git::reference::Reader + sigrefs::git::reference::Writer, R: HasRepoId, S: signature::Signer, S: signature::Verifier, { self.save_with(namespace, committer, repo, signer, true) } fn save_with( self, namespace: NodeId, committer: sigrefs::git::Committer, repo: &R, signer: &S, force: bool, ) -> Result where R: sigrefs::git::object::Reader + sigrefs::git::object::Writer, R: sigrefs::git::reference::Reader + sigrefs::git::reference::Writer, R: HasRepoId, S: signature::Signer, S: signature::Verifier, { let msg = "Update signed refs\n"; let reflog = format!("Save {} signed references", self.len()); let writer = sigrefs::write::SignedRefsWriter::new(self, repo.rid(), namespace, repo, signer); let update = if force { writer.force_write(committer, msg.to_string(), reflog)? } else { writer.write(committer, msg.to_string(), reflog)? }; match update { sigrefs::write::Update::Changed { entry, level } => { Ok(entry.into_sigrefs_at(namespace, level)) } sigrefs::write::Update::Unchanged { verified } => { Ok(verified.into_sigrefs_at(namespace)) } } } /// Get a particular ref. pub fn get(&self, name: &git::fmt::Qualified) -> Option { self.0.get(name.to_ref_string().as_refstr()).copied() } /// Get a particular head ref. pub fn head(&self, name: impl AsRef) -> Option { let branch = git::fmt::refname!("refs/heads").join(name); self.0.get(&branch).copied() } /// Create refs from a canonical representation. fn from_canonical(bytes: &[u8]) -> Result { let reader = BufReader::new(bytes); let mut refs = BTreeMap::new(); for line in reader.lines() { let line = line?; let (oid, name) = line .split_once(' ') .ok_or(canonical::Error::InvalidFormat)?; let name = git::fmt::RefString::try_from(name)?; let oid = Oid::from_str(oid).map_err(|_| canonical::Error::InvalidFormat)?; if oid.is_zero() || name.as_refstr() == SIGREFS_BRANCH.as_ref() { continue; } refs.insert(name, oid); } Ok(Self(refs)) } fn canonical(&self) -> Vec { let mut buf = String::new(); for (name, oid) in self.0.iter() { debug_assert_ne!(oid, &Oid::sha1_zero()); debug_assert_ne!(name, &SIGREFS_BRANCH.to_ref_string()); buf.push_str(&oid.to_string()); buf.push(' '); buf.push_str(name); buf.push('\n'); } buf.into_bytes() } pub fn insert(&mut self, refname: git::fmt::RefString, target: Oid) -> Option { if target.is_zero() { self.0.remove(&refname) } else { self.0.insert(refname, target) } } pub(crate) fn keys<'a>( &'a self, ) -> std::collections::btree_map::Keys<'a, git::fmt::RefString, Oid> { self.0.keys() } #[cfg(any(test, feature = "test"))] pub(crate) fn values<'a>( &'a self, ) -> std::collections::btree_map::Values<'a, git::fmt::RefString, Oid> { self.0.values() } pub fn iter<'a>(&'a self) -> std::collections::btree_map::Iter<'a, git::fmt::RefString, Oid> { self.0.iter() } pub fn len(&self) -> usize { self.0.len() } pub fn is_empty(&self) -> bool { self.0.is_empty() } pub(super) fn remove_sigrefs(&mut self) -> Option { self.0.remove(&SIGREFS_BRANCH.to_ref_string()) } /// Add a reference with name [`crate::git::refs::storage::SIGREFS_PARENT`] /// and given target OID to this set of refs. #[inline] fn add_parent(&mut self, commit: Oid) -> Option { self.0.insert(SIGREFS_PARENT.to_ref_string(), commit) } /// Removes reference with name [`crate::git::refs::storage::SIGREFS_PARENT`] /// from this set of refs, if it exists. /// Absence of a reference with such name is ignored. #[inline] fn remove_parent(&mut self) -> Option { self.0.remove(&SIGREFS_PARENT.to_ref_string()) } } impl IntoIterator for Refs { type Item = (git::fmt::RefString, Oid); type IntoIter = std::collections::btree_map::IntoIter; fn into_iter(self) -> Self::IntoIter { self.0.into_iter() } } impl From for BTreeMap { fn from(refs: Refs) -> Self { refs.0 } } impl From for Refs where I: Iterator, { fn from(value: I) -> Self { let mut refs = Self::new(); for (refname, target) in value { refs.insert(refname, target); } refs } } /// The Signed References feature has evolved over time. /// This enum captures the corresponding "feature level". /// /// Feature levels are monotonic, in the sense that a greater feature level /// encompasses all the features of smaller ones. #[derive( Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Default, Hash, Serialize, Deserialize, )] #[serde(rename_all = "camelCase")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] #[non_exhaustive] pub enum FeatureLevel { /// The lowest feature level, with least security. It is vulnerable to /// graft attacks and replay attacks. #[default] None, #[cfg_attr( feature = "schemars", schemars(description = "\ An intermediate feature level, which protects against graft attacks \ but is vulnerable to replay attacks. \ Introduced in Radicle 1.1.0, in commit \ `989edacd564fa658358f5ccfd08c243c5ebd8cda`.\ ") )] /// Requires [`IDENTITY_ROOT`]. Root, #[cfg_attr( feature = "schemars", schemars(description = "\ The highest feature level known, which protects against graft attacks \ and replay attacks. \ Introduced in Radicle 1.7.0, in commit \ `d3bc868e84c334f113806df1737f52cc57c5453d`.\ ") )] /// Requires [`SIGREFS_PARENT`]. Parent, } impl FeatureLevel { pub const LATEST: Self = FeatureLevel::Parent; } impl std::fmt::Display for FeatureLevel { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { let s = match &self { Self::None => "none", Self::Root => "root", Self::Parent => "parent", }; f.write_str(s) } } /// The content-addressable information required to load a remote's /// `rad/sigrefs`. /// /// Use [`RefsAt::remote`] and [`RefsAt::at`] with [`SignedRefs::load_at`] to /// attempt loading the expected signed references. /// /// `RefsAt` can also be used for communicating announcements of updates /// references to other nodes. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] pub struct RefsAt { /// The remote namespace of the `rad/sigrefs`. pub remote: RemoteId, /// The commit SHA that `rad/sigrefs` points to. pub at: Oid, } impl RefsAt { pub fn new(repo: &R, remote: RemoteId) -> Result where R: sigrefs::git::reference::Reader, { let at = repo .find_reference( &storage::refs::SIGREFS_BRANCH.with_namespace(git::fmt::Component::from(&remote)), ) .map_err(sigrefs::read::error::Read::FindReference)? .ok_or_else(|| sigrefs::read::error::Read::MissingSigrefs { namespace: remote })?; Ok(RefsAt { remote, at }) } pub fn path(&self) -> &git::fmt::Qualified<'_> { &SIGREFS_BRANCH } } impl std::fmt::Display for RefsAt { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{} @ {}", self.remote, self.at) } } /// Verified [`SignedRefs`] that keeps track of their content address /// [`Oid`]. /// /// The signature is a cryptographic signature over the refs. /// This allows us to easily verify if a set of refs /// came from a particular key. #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct SignedRefs { /// The signed refs. refs: Refs, /// The signature of the signer over the refs. #[serde(skip)] signature: Signature, /// This is the remote under which these refs exist, and the public key of the signer. id: PublicKey, #[serde(skip)] level: FeatureLevel, /// The [`Oid`] of the parent commit of the commit in which. #[serde(skip)] parent: Option, pub at: Oid, } impl SignedRefs { /// Returns the [`NodeId`] of the [`SignedRefs`]. pub fn id(&self) -> NodeId { self.id } /// Returns the [`Refs`] of the [`SignedRefs`]. pub fn refs(&self) -> &Refs { &self.refs } /// Returns the [`FeatureLevel`] computed for the signed references. pub fn feature_level(&self) -> FeatureLevel { self.level } /// The [`Oid`] of the parent commit, or [`None`] if these signed references /// were found at a root commit. pub fn parent(&self) -> Option<&Oid> { self.parent.as_ref() } /// Load the [`SignedRefs`] found under `remote`'s [`SIGREFS_BRANCH`]. /// /// This will return `None` if the branch was not found, all other /// errors are returned. pub fn load(remote: RemoteId, repo: &R) -> Result, sigrefs::read::error::Read> where R: HasRepoId, R: sigrefs::git::object::Reader + sigrefs::git::reference::Reader, { Self::load_internal(remote, repo, sigrefs::read::Tip::Reference(remote)) } pub fn load_at( oid: Oid, remote: RemoteId, repo: &R, ) -> Result, sigrefs::read::error::Read> where R: HasRepoId, R: sigrefs::git::object::Reader + sigrefs::git::reference::Reader, { Self::load_internal(remote, repo, sigrefs::read::Tip::Commit(oid)) } fn load_internal( remote: RemoteId, repo: &R, tip: Tip, ) -> Result, sigrefs::read::error::Read> where R: HasRepoId, R: sigrefs::git::object::Reader + sigrefs::git::reference::Reader, { let root = repo.rid(); match sigrefs::SignedRefsReader::new(root, tip, repo, &remote).read() { Ok(latest) => Ok(Some(latest.into_sigrefs_at(remote))), Err(sigrefs::read::error::Read::MissingSigrefs { namespace }) => { debug_assert_eq!(namespace, remote); Ok(None) } Err(err) => Err(err), } } pub fn iter(&self) -> impl Iterator { self.refs.iter() } } impl Deref for SignedRefs { type Target = Refs; fn deref(&self) -> &Self::Target { &self.refs } } pub mod canonical { use super::*; #[derive(Debug, thiserror::Error)] pub enum Error { #[error(transparent)] InvalidRef(#[from] git::fmt::Error), #[error("invalid canonical format")] InvalidFormat, #[error(transparent)] Io(#[from] io::Error), #[error(transparent)] Git(#[from] git::raw::Error), } } #[cfg(test)] #[allow(clippy::unwrap_used)] mod tests { use qcheck_macros::quickcheck; use storage::{RemoteRepository, SignRepository, WriteStorage, git::transport}; use super::*; use crate::assert_matches; use crate::node::device::Device; use crate::storage::WriteRepository as _; use crate::{Storage, cob::Title, cob::identity::Identity, rad, test::fixtures}; #[quickcheck] fn prop_canonical_roundtrip(refs: Refs) { let encoded = refs.canonical(); let decoded = Refs::from_canonical(&encoded).unwrap(); assert_eq!(refs, decoded); } #[test] // Test that a user's signed refs are tied to a specific RID, and they can't simply be // used in a different repository. // // We create two repos, `paris` and `london`, and we copy over Bob's signed refs from `paris` // to `london`. We expect that this does not cause the canonical head of the `london` repo // to change, despite Bob being a delegate of both repos, because the refs were signed for the // `paris` repo. We also don't expected the signed refs to validate without error. fn test_rid_verification() { let tmp = tempfile::tempdir().unwrap(); let alice = Device::mock(); let bob = Device::mock(); let storage = &Storage::open(tmp.path().join("storage"), fixtures::user()).unwrap(); transport::local::register(storage.clone()); // Alice creates "paris" repo. let (paris_repo, paris_head) = fixtures::repository(tmp.path().join("paris")); let (paris_rid, paris_doc, _) = rad::init( &paris_repo, "paris".try_into().unwrap(), "Paris repository", git::fmt::refname!("master"), Default::default(), &alice, storage, ) .unwrap(); // Alice creates "london" repo. let (london_repo, _london_head) = fixtures::repository(tmp.path().join("london")); let (london_rid, london_doc, _) = rad::init( &london_repo, "london".try_into().unwrap(), "London repository", git::fmt::refname!("master"), Default::default(), &alice, storage, ) .unwrap(); assert_ne!(london_rid, paris_rid); log::debug!(target: "test", "London RID: {london_rid}"); log::debug!(target: "test", "Paris RID: {paris_rid}"); let paris = storage.repository_mut(paris_rid).unwrap(); let london = storage.repository_mut(london_rid).unwrap(); // Bob is added to both repos as a delegate, by Alice. { let paris_doc = paris_doc .with_edits(|doc| { doc.delegates.push(bob.public_key().into()); }) .unwrap(); let london_doc = london_doc .with_edits(|doc| { doc.delegates.push(bob.public_key().into()); }) .unwrap(); let mut paris_ident = Identity::load_mut(&paris, &alice).unwrap(); let mut london_ident = Identity::load_mut(&london, &alice).unwrap(); paris_ident .update(Title::new("Add Bob").unwrap(), "", &paris_doc) .unwrap(); london_ident .update(Title::new("Add Bob").unwrap(), "", &london_doc) .unwrap(); } // Now Bob checks out a copy of the `paris` repository and pushes a commit to the // default branch (master). We store the OID of that commit in `bob_head`, as this // is the commit we will try to get the `london` repo to point to. let (bob_paris_sigrefs, bob_head) = { let bob_working = rad::checkout( paris.id, bob.public_key(), tmp.path().join("working"), &storage, false, ) .unwrap(); let paris_head = bob_working.find_commit(paris_head).unwrap(); let bob_sig = git::raw::Signature::now("bob", "bob@example.com").unwrap(); let bob_head = git::empty_commit( &bob_working, &paris_head, git::fmt::refname!("refs/heads/master").as_refstr(), "Bob's commit", &bob_sig, ) .unwrap(); let mut bob_master_ref = bob_working.find_reference("refs/heads/master").unwrap(); bob_master_ref.set_target(bob_head.id(), "").unwrap(); bob_working .find_remote("rad") .unwrap() .push(&["refs/heads/master"], None) .unwrap(); let sigrefs = paris.sign_refs(&bob).unwrap(); assert_eq!( sigrefs .get(&crate::git::fmt::qualified!("refs/heads/master")) .unwrap(), bob_head.id() ); (sigrefs, bob_head.id()) }; { // Sanity check: make sure the default branches don't already match between Alice and Bob. let alice_paris_sigrefs = SignedRefs::load(*alice.public_key(), &paris) .unwrap() .unwrap(); assert_ne!( alice_paris_sigrefs .get(&crate::git::fmt::qualified!("refs/heads/master")) .unwrap(), bob_paris_sigrefs .get(&crate::git::fmt::qualified!("refs/heads/master")) .unwrap() ); } { // For the graft to work, we also have to copy over the objects that Bob created in // `paris`, so that the grafted signed refs point to valid objects. let paris_odb = paris.raw().odb().unwrap(); let london_odb = london.raw().odb().unwrap(); paris_odb .foreach(|oid| { let obj = paris_odb.read(*oid).unwrap(); london_odb.write(obj.kind(), obj.data()).unwrap(); true }) .unwrap(); } // Now we're going to "graft" Bob's signed refs from `paris` to `london`. // We save Bob's `paris` signed refs in the `london` repo, performing the graft, and update // Bob's `master` branch reference to point to his commit, created in the `paris` repo. This // only modifies his own namespace. Note that anyone (eg. Eve) could create a reference // under her copy of Bob's namespace, and this would only be rejected during signed ref // validation. { let name = &SIGREFS_BRANCH.with_namespace(git::fmt::Component::from(bob.node_id())); let id = paris.backend.refname_to_id(name.as_str()).unwrap(); london .backend .reference(name.as_str(), id, true, "Graft attack") .unwrap(); } london .raw() .reference( git::refs::storage::branch_of(bob.public_key(), &git::fmt::refname!("master")) .as_str(), bob_head, false, "", ) .unwrap(); // Due to the verification, we get a validation error when trying to load Bob's remote. // The graft is not allowed. assert_matches!( london.remote(bob.public_key()), Err(Error::Read(sigrefs::read::error::Read::Verify(sigrefs::read::error::Verify::MismatchedIdentity { expected, found, sigrefs_commit: _, identity_commit: _, }))) if expected == london_rid && found == paris_rid ); } }