diff --git a/node/src/client.rs b/node/src/client.rs index 0b9ad2e2..f8c1adbf 100644 --- a/node/src/client.rs +++ b/node/src/client.rs @@ -55,7 +55,7 @@ pub struct Client { } impl Client { - pub fn new, S: Signer>( + pub fn new, S: Signer + 'static>( path: P, signer: S, ) -> Result { diff --git a/node/src/crypto.rs b/node/src/crypto.rs index 7066e521..1d8367ac 100644 --- a/node/src/crypto.rs +++ b/node/src/crypto.rs @@ -5,9 +5,17 @@ use ed25519_consensus as ed25519; use serde::{Deserialize, Serialize}; use thiserror::Error; +pub use ed25519::Error; pub use ed25519::Signature; -pub trait Signer: Send + Sync + 'static { +/// Verified (used as type witness). +#[derive(Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Verified; +/// Unverified (used as type witness). +#[derive(Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Unverified; + +pub trait Signer: Send + Sync { /// Return this signer's public/verification key. fn public_key(&self) -> &PublicKey; /// Sign a message and return the signature. @@ -27,6 +35,19 @@ where } } +impl Signer for &T +where + T: Signer + ?Sized, +{ + fn sign(&self, msg: &[u8]) -> Signature { + self.deref().sign(msg) + } + + fn public_key(&self) -> &PublicKey { + self.deref().public_key() + } +} + /// The public/verification key. #[derive(Serialize, Deserialize, Eq, Debug, Copy, Clone)] #[serde(transparent)] diff --git a/node/src/git.rs b/node/src/git.rs index 30f2878f..37245cdf 100644 --- a/node/src/git.rs +++ b/node/src/git.rs @@ -5,7 +5,8 @@ use radicle_git_ext as git_ext; use crate::collections::HashMap; use crate::identity::UserId; -use crate::storage::{Remote, Remotes, Unverified}; +use crate::storage::refs::Refs; +use crate::storage::RemoteId; pub use git_ext::Oid; pub use git_url::Url; @@ -30,7 +31,7 @@ pub enum ListRefsError { } /// List remote refs of a project, given the remote URL. -pub fn list_remotes(url: &Url) -> Result, ListRefsError> { +pub fn remote_refs(url: &Url) -> Result, ListRefsError> { let url = url.to_string(); let mut remotes = HashMap::default(); let mut remote = git2::Remote::create_detached(&url)?; @@ -40,14 +41,12 @@ pub fn list_remotes(url: &Url) -> Result, ListRefsError> { let refs = remote.list()?; for r in refs { let (id, refname) = parse_ref::(r.name())?; - let entry = remotes - .entry(id) - .or_insert_with(|| Remote::new(id, HashMap::default())); + let entry = remotes.entry(id).or_insert_with(Refs::default); - entry.refs.insert(refname.to_string(), r.oid().into()); + entry.insert(refname.to_string(), r.oid().into()); } - Ok(Remotes::new(remotes)) + Ok(remotes) } /// Parse a ref string. @@ -90,6 +89,7 @@ pub fn commit<'a>( let sig = git2::Signature::now(user, "anonymous@radicle.xyz")?; let tree_id = repo.index()?.write_tree()?; let tree = repo.find_tree(tree_id)?; + // TODO: Take the ref as parameter. let oid = repo.commit(None, &sig, &sig, message, &tree, &[parent])?; let commit = repo.find_commit(oid).unwrap(); diff --git a/node/src/protocol.rs b/node/src/protocol.rs index 988450f6..52b1f343 100644 --- a/node/src/protocol.rs +++ b/node/src/protocol.rs @@ -24,7 +24,7 @@ use crate::protocol::config::ProjectTracking; use crate::protocol::message::Message; use crate::protocol::peer::{Peer, PeerError, PeerState}; use crate::storage::{self, ReadRepository, WriteRepository}; -use crate::storage::{Inventory, ReadStorage, Remotes, Unverified, WriteStorage}; +use crate::storage::{Inventory, ReadStorage, Remotes, WriteStorage}; pub use crate::protocol::config::{Config, Network}; @@ -339,21 +339,11 @@ where let user = *self.storage.user_id(); let repo = self.storage.repository(&proj).unwrap(); let remote = repo.remote(&user).unwrap(); - let refs = remote.refs; - let signature = self - .signer - .sign(serde_json::to_vec(&refs).unwrap().as_slice()); let peers = self.peers.negotiated().map(|(_, p)| p.addr); + let refs = remote.refs.unverified(); - self.context.broadcast( - Message::RefsUpdate { - proj, - user, - refs, - signature, - }, - peers, - ); + self.context + .broadcast(Message::RefsUpdate { proj, user, refs }, peers); } } } @@ -556,7 +546,7 @@ impl Iterator for Protocol { #[derive(Debug)] pub struct Lookup { /// Whether the project was found locally or not. - pub local: Option>, + pub local: Option>, /// A list of remote peers on which the project is known to exist. pub remote: Vec, } diff --git a/node/src/protocol/message.rs b/node/src/protocol/message.rs index 7d7754bd..d6c3c3d7 100644 --- a/node/src/protocol/message.rs +++ b/node/src/protocol/message.rs @@ -7,6 +7,7 @@ use crate::crypto; use crate::identity::{ProjId, UserId}; use crate::protocol::{Context, NodeId, Timestamp, PROTOCOL_VERSION}; use crate::storage; +use crate::storage::refs::SignedRefs; /// Message envelope. All messages sent over the network are wrapped in this type. #[derive(Debug, Serialize, Deserialize)] @@ -104,9 +105,7 @@ pub enum Message { /// User signing. user: UserId, /// Updated refs. - refs: storage::Refs, - /// Signature over the refs. - signature: crypto::Signature, + refs: SignedRefs, }, } diff --git a/node/src/protocol/peer.rs b/node/src/protocol/peer.rs index d36c761b..696f6a34 100644 --- a/node/src/protocol/peer.rs +++ b/node/src/protocol/peer.rs @@ -194,18 +194,8 @@ impl Peer { })); } } - ( - PeerState::Negotiated { git, .. }, - Message::RefsUpdate { - proj, - user, - refs, - signature, - }, - ) => { - let bytes = serde_json::to_vec(&refs).unwrap(); - - if user.verify(&signature, &bytes).is_ok() { + (PeerState::Negotiated { git, .. }, Message::RefsUpdate { proj, user, refs }) => { + if refs.verified(&user).is_ok() { // TODO: Buffer/throttle fetches. // TODO: Also pass the updated refs so that we can check whether // we need to fetch or not. diff --git a/node/src/rad.rs b/node/src/rad.rs index bb6adee4..7d8cdbb0 100644 --- a/node/src/rad.rs +++ b/node/src/rad.rs @@ -4,9 +4,11 @@ use git_url::Url; use nonempty::NonEmpty; use thiserror::Error; +use crate::crypto::Verified; use crate::git; use crate::identity::ProjId; use crate::storage::git::RADICLE_ID_REF; +use crate::storage::refs::SignedRefs; use crate::storage::{BranchName, ReadRepository as _}; use crate::{identity, storage}; @@ -37,7 +39,7 @@ pub fn init( description: &str, default_branch: BranchName, storage: S, -) -> Result { +) -> Result<(ProjId, SignedRefs), InitError> { let user_id = storage.user_id(); let delegate = identity::Delegate { // TODO: Use actual user name. @@ -114,8 +116,9 @@ pub fn init( ], None, )?; + let signed = storage.sign_refs(&project)?; - Ok(id) + Ok((id, signed)) } #[cfg(test)] @@ -136,7 +139,7 @@ mod tests { let head = git::commit(&repo, &head, "Second commit", "anonymous").unwrap(); let _branch = repo.branch("master", &head, false).unwrap(); - init( + let (_id, _refs) = init( &repo, "acme", "Acme's repo", diff --git a/node/src/storage.rs b/node/src/storage.rs index cdc8887b..f62d77de 100644 --- a/node/src/storage.rs +++ b/node/src/storage.rs @@ -1,4 +1,5 @@ pub mod git; +pub mod refs; use std::collections::hash_map; use std::io; @@ -6,19 +7,21 @@ use std::marker::PhantomData; use std::ops::{Deref, DerefMut}; use std::path::Path; -use git_url::Url; -use once_cell::sync::Lazy; +use radicle_git_ext as git_ext; use serde::{Deserialize, Serialize}; use thiserror::Error; pub use radicle_git_ext::Oid; use crate::collections::HashMap; +use crate::crypto::{self, Unverified, Verified}; use crate::git::RefError; +use crate::git::Url; use crate::identity; use crate::identity::{ProjId, ProjIdError, UserId}; +use crate::storage::refs::Refs; -pub static IDENTITY_PATH: Lazy<&Path> = Lazy::new(|| Path::new(".rad/identity.toml")); +use self::refs::SignedRefs; pub type BranchName = String; pub type Inventory = Vec; @@ -30,6 +33,8 @@ pub enum Error { InvalidRef, #[error("git reference error: {0}")] Ref(#[from] RefError), + #[error(transparent)] + Refs(#[from] refs::Error), #[error("git: {0}")] Git(#[from] git2::Error), #[error("id: {0}")] @@ -42,28 +47,38 @@ pub enum Error { InvalidHead, } -pub type Refs = HashMap; pub type RemoteId = UserId; pub type RefName = String; -/// Verified (used as type witness). -#[derive(Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct Verified; -/// Unverified (used as type witness). -#[derive(Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct Unverified; - /// Project remotes. Tracks the git state of a project. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Remotes(HashMap>); -impl Remotes { - pub fn new(remotes: HashMap>) -> Self { +impl Deref for Remotes { + type Target = HashMap>; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl Remotes { + pub fn new(remotes: HashMap>) -> Self { Self(remotes) } } -impl Default for Remotes { +impl Remotes { + pub fn unverified(self) -> Remotes { + Remotes( + self.into_iter() + .map(|(id, r)| (id, r.unverified())) + .collect(), + ) + } +} + +impl Default for Remotes { fn default() -> Self { Self(HashMap::default()) } @@ -90,13 +105,25 @@ impl Into>> for Remotes { } } +#[allow(clippy::from_over_into)] +impl Into> for Remotes { + fn into(self) -> HashMap { + let mut remotes = HashMap::with_hasher(fastrand::Rng::new().into()); + + for (k, v) in self.into_iter() { + remotes.insert(k, v.refs.into()); + } + remotes + } +} + /// A project remote. #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] pub struct Remote { /// ID of remote. pub id: UserId, /// Git references published under this remote, and their hashes. - pub refs: HashMap, + pub refs: SignedRefs, /// Whether this remote is of a project delegate. pub delegate: bool, /// Whether the remote is verified or not, ie. whether its signed refs were checked. @@ -104,21 +131,45 @@ pub struct Remote { verified: PhantomData, } -impl Remote { - pub fn new(id: UserId, refs: HashMap) -> Self { +impl Remote { + pub fn new(id: UserId, refs: impl Into>) -> Self { Self { id, - refs, + refs: refs.into(), delegate: false, verified: PhantomData, } } } +impl Remote { + pub fn verified(self) -> Result, crypto::Error> { + let refs = self.refs.verified(&self.id)?; + + Ok(Remote { + id: self.id, + refs, + delegate: self.delegate, + verified: PhantomData, + }) + } +} + +impl Remote { + pub fn unverified(self) -> Remote { + Remote { + id: self.id, + refs: self.refs.unverified(), + delegate: self.delegate, + verified: PhantomData, + } + } +} + pub trait ReadStorage { fn user_id(&self) -> &UserId; fn url(&self) -> Url; - fn get(&self, proj: &ProjId) -> Result>, Error>; + fn get(&self, proj: &ProjId) -> Result>, Error>; fn inventory(&self) -> Result; } @@ -126,16 +177,26 @@ pub trait WriteStorage: ReadStorage { type Repository: WriteRepository; fn repository(&self, proj: &ProjId) -> Result; + fn sign_refs(&self, repository: &Self::Repository) -> Result, Error>; } pub trait ReadRepository { fn path(&self) -> &Path; - fn remote(&self, user: &UserId) -> Result, Error>; - fn remotes(&self) -> Result, Error>; + fn blob_at<'a>(&'a self, oid: Oid, path: &'a Path) -> Result, git_ext::Error>; + fn reference( + &self, + user: &UserId, + reference: &str, + ) -> Result, git2::Error>; + fn reference_oid(&self, user: &UserId, reference: &str) -> Result, git2::Error>; + fn references(&self, user: &UserId) -> Result; + fn remote(&self, user: &UserId) -> Result, refs::Error>; + fn remotes(&self) -> Result, refs::Error>; } pub trait WriteRepository: ReadRepository { fn fetch(&mut self, url: &Url) -> Result<(), git2::Error>; + fn raw(&self) -> &git2::Repository; } impl ReadStorage for T @@ -155,7 +216,7 @@ where self.deref().inventory() } - fn get(&self, proj: &ProjId) -> Result>, Error> { + fn get(&self, proj: &ProjId) -> Result>, Error> { self.deref().get(proj) } } @@ -170,6 +231,10 @@ where fn repository(&self, proj: &ProjId) -> Result { self.deref().repository(proj) } + + fn sign_refs(&self, repository: &S::Repository) -> Result, Error> { + self.deref().sign_refs(repository) + } } #[cfg(test)] diff --git a/node/src/storage/git.rs b/node/src/storage/git.rs index 915cb4d2..957ab894 100644 --- a/node/src/storage/git.rs +++ b/node/src/storage/git.rs @@ -1,28 +1,32 @@ +use std::collections::BTreeMap; use std::path::{Path, PathBuf}; use std::sync::Arc; use std::{fmt, fs, io}; use git_ref_format::refspec; -use git_url::Url; use once_cell::sync::Lazy; use radicle_git_ext as git_ext; pub use radicle_git_ext::Oid; use crate::collections::HashMap; -use crate::crypto::Signer; +use crate::crypto::{Signer, Verified}; use crate::git; use crate::identity::{ProjId, UserId}; - -use super::{ - Error, Inventory, ReadRepository, ReadStorage, Remote, Remotes, Unverified, WriteRepository, - WriteStorage, +use crate::storage::refs; +use crate::storage::refs::{Refs, SignedRefs}; +use crate::storage::{ + Error, Inventory, ReadRepository, ReadStorage, Remote, Remotes, WriteRepository, WriteStorage, }; +use super::RemoteId; + pub static RADICLE_ID_REF: Lazy = Lazy::new(|| refspec::pattern!("heads/radicle/id")); pub static REMOTES_GLOB: Lazy = Lazy::new(|| refspec::pattern!("refs/remotes/*")); +pub static SIGNATURES_GLOB: Lazy = + Lazy::new(|| refspec::pattern!("refs/remotes/*/radicle/signature")); pub struct Storage { path: PathBuf, @@ -40,16 +44,28 @@ impl ReadStorage for Storage { self.signer.public_key() } - fn url(&self) -> Url { - Url { + fn url(&self) -> git::Url { + git::Url { scheme: git_url::Scheme::File, host: Some(self.path.to_string_lossy().to_string()), - ..Url::default() + ..git::Url::default() } } - fn get(&self, _id: &ProjId) -> Result>, Error> { - todo!() + fn get(&self, id: &ProjId) -> Result>, Error> { + // TODO: Don't create a repo here if it doesn't exist? + // Perhaps for checking we could have a `contains` method? + match self.repository(id) { + Ok(r) => { + let remotes = r.remotes()?; + if remotes.is_empty() { + Ok(None) + } else { + Ok(Some(remotes)) + } + } + Err(e) => Err(e), + } } fn inventory(&self) -> Result { @@ -63,10 +79,23 @@ impl WriteStorage for Storage { fn repository(&self, proj: &ProjId) -> Result { Repository::open(self.path.join(proj.to_string())) } + + fn sign_refs(&self, repository: &Repository) -> Result, Error> { + let remote = self.signer.public_key(); + let refs = repository.references(remote)?; + let signed = refs.signed(self.signer.clone())?; + + signed.save(remote, repository)?; + + Ok(signed) + } } impl Storage { - pub fn open>(path: P, signer: impl Signer) -> Result { + pub fn open, S: Signer + 'static>( + path: P, + signer: S, + ) -> Result { let path = path.as_ref().to_path_buf(); match fs::create_dir_all(&path) { @@ -89,6 +118,13 @@ impl Storage { self.signer.clone() } + pub fn with_signer(&self, signer: impl Signer + 'static) -> Self { + Self { + path: self.path.clone(), + signer: Arc::new(signer), + } + } + pub fn projects(&self) -> Result, Error> { let mut projects = Vec::new(); @@ -100,10 +136,28 @@ impl Storage { } Ok(projects) } + + pub fn inspect(&self) -> Result<(), Error> { + for proj in self.projects()? { + let repo = self.repository(&proj)?; + + for r in repo.raw().references()? { + let r = r?; + let name = r.name().ok_or(Error::InvalidRef)?; + let oid = r.target().ok_or(Error::InvalidRef)?; + + println!("{} {} {}", proj, oid, name); + } + } + Ok(()) + } } pub struct Repository { pub(crate) backend: git2::Repository, + // TODO: Add project id here so we can refer to it + // in a bunch of places. We could write it to the + // git config for later. } impl Repository { @@ -117,6 +171,11 @@ impl Repository { .no_reinit(true) .external_template(false), )?; + let mut config = backend.config()?; + + // TODO: Get ahold of user name and/or key. + config.set_str("user.name", "radicle")?; + config.set_str("user.email", "radicle@localhost")?; Ok(backend) } @@ -127,15 +186,15 @@ impl Repository { Ok(Self { backend }) } - pub fn find_reference(&self, remote: &UserId, name: &str) -> Result { - let name = format!("refs/remotes/{}/{}", remote, name); - let target = self - .backend - .find_reference(&name)? - .target() - .ok_or(Error::InvalidRef)?; + pub fn inspect(&self) -> Result<(), Error> { + for r in self.backend.references()? { + let r = r?; + let name = r.name().ok_or(Error::InvalidRef)?; + let oid = r.target().ok_or(Error::InvalidRef)?; - Ok(target.into()) + println!("{} {}", oid, name); + } + Ok(()) } } @@ -144,38 +203,69 @@ impl ReadRepository for Repository { self.backend.path() } - fn remote(&self, user: &UserId) -> Result, Error> { - // TODO: Only fetch standard refs. + fn blob_at<'a>(&'a self, oid: Oid, path: &'a Path) -> Result, git_ext::Error> { + git_ext::Blob::At { + object: oid.into(), + path, + } + .get(&self.backend) + } + + fn reference( + &self, + remote: &RemoteId, + name: &str, + ) -> Result, git2::Error> { + let name = format!("refs/remotes/{remote}/{name}"); + self.backend.find_reference(&name).map(Some).or_else(|e| { + if git_ext::is_not_found_err(&e) { + Ok(None) + } else { + Err(e) + } + }) + } + + fn reference_oid(&self, user: &RemoteId, reference: &str) -> Result, git2::Error> { + let reference = self.reference(user, reference)?; + Ok(reference.and_then(|r| r.target().map(|o| o.into()))) + } + + fn remote(&self, remote: &RemoteId) -> Result, refs::Error> { + let refs = SignedRefs::load(remote, self)?; + Ok(Remote::new(*remote, refs)) + } + + fn references(&self, remote: &RemoteId) -> Result { + // TODO: Only return known refs, eg. heads/ rad/ tags/ etc.. let entries = self .backend - .references_glob(format!("refs/remotes/{}/*", user).as_str())?; - let mut refs = HashMap::default(); + .references_glob(format!("refs/remotes/{remote}/*").as_str())?; + let mut refs = BTreeMap::new(); for e in entries { let e = e?; let name = e.name().ok_or(Error::InvalidRef)?; - let (_, refname) = git::parse_ref::(name)?; + let (_, refname) = git::parse_ref::(name)?; let oid = e.target().ok_or(Error::InvalidRef)?; refs.insert(refname.to_string(), oid.into()); } - Ok(Remote::new(*user, refs)) + Ok(refs.into()) } - fn remotes(&self) -> Result, Error> { - let refs = self.backend.references_glob(REMOTES_GLOB.as_str())?; + fn remotes(&self) -> Result, refs::Error> { + // TODO: Should use the id glob here instead of signature. + let refs = self.backend.references_glob(SIGNATURES_GLOB.as_str())?; let mut remotes = HashMap::default(); for r in refs { let r = r?; - let name = r.name().ok_or(Error::InvalidRef)?; - let (id, refname) = git::parse_ref::(name)?; - let entry = remotes - .entry(id) - .or_insert_with(|| Remote::new(id, HashMap::default())); - let oid = r.target().ok_or(Error::InvalidRef)?; + let name = r.name().ok_or(refs::Error::InvalidRef)?; + let (id, _) = git::parse_ref::(name)?; + let remote = self.remote(&id)?; - entry.refs.insert(refname.to_string(), oid.into()); + remotes.insert(id, remote); } Ok(Remotes::new(remotes)) } @@ -183,7 +273,7 @@ impl ReadRepository for Repository { impl WriteRepository for Repository { /// Fetch all remotes of a project from the given URL. - fn fetch(&mut self, url: &Url) -> Result<(), git2::Error> { + fn fetch(&mut self, url: &git::Url) -> Result<(), git2::Error> { // TODO: Have function to fetch specific remotes. // TODO: Return meaningful info on success. // @@ -207,6 +297,10 @@ impl WriteRepository for Repository { Ok(()) } + + fn raw(&self) -> &git2::Repository { + &self.backend + } } impl From for Repository { @@ -219,28 +313,34 @@ impl From for Repository { mod tests { use super::*; use crate::git; + use crate::storage::refs::SIGNATURE_REF; use crate::storage::{ReadStorage, WriteRepository}; + use crate::test::arbitrary; use crate::test::crypto::MockSigner; use crate::test::fixtures; - use git_url::Url; #[test] - fn test_list_remotes() { + fn test_remote_refs() { let dir = tempfile::tempdir().unwrap(); let storage = fixtures::storage(dir.path()); let inv = storage.inventory().unwrap(); let proj = inv.first().unwrap(); - let refs = git::list_remotes(&Url { + let mut refs = git::remote_refs(&git::Url { host: Some(dir.path().to_string_lossy().to_string()), scheme: git_url::Scheme::File, path: format!("/{}", proj).into(), - ..Url::default() + ..git::Url::default() }) .unwrap(); - let remotes = storage.repository(proj).unwrap().remotes().unwrap(); + let project = storage.repository(proj).unwrap(); + let remotes = project.remotes().unwrap(); - assert_eq!(refs, remotes); + // Strip the remote refs of sigrefs so we can compare them. + for remote in refs.values_mut() { + remote.remove(SIGNATURE_REF).unwrap(); + } + assert_eq!(refs, remotes.into()); } #[test] @@ -256,27 +356,56 @@ mod tests { // Have Bob fetch Alice's refs. bob.repository(proj) .unwrap() - .fetch(&Url { + .fetch(&git::Url { host: Some(alice.path().to_string_lossy().to_string()), scheme: git_url::Scheme::File, path: format!("/{}", proj).into(), - ..Url::default() + ..git::Url::default() }) .unwrap(); for (id, _) in remotes.into_iter() { - let alice_oid = alice - .repository(proj) - .unwrap() - .find_reference(&id, refname) - .unwrap(); - let bob_oid = bob - .repository(proj) - .unwrap() - .find_reference(&id, refname) - .unwrap(); + let alice_repo = alice.repository(proj).unwrap(); + let alice_oid = alice_repo.reference(&id, refname).unwrap().unwrap(); - assert_eq!(alice_oid, bob_oid); + let bob_repo = bob.repository(proj).unwrap(); + let bob_oid = bob_repo.reference(&id, refname).unwrap().unwrap(); + + assert_eq!(alice_oid.target(), bob_oid.target()); } } + + #[test] + fn test_sign_refs() { + let tmp = tempfile::tempdir().unwrap(); + let mut rng = fastrand::Rng::new(); + let signer = MockSigner::new(&mut rng); + let storage = Storage::open(tmp.path(), signer).unwrap(); + let proj_id = arbitrary::gen::(1); + let alice = *storage.user_id(); + let project = storage.repository(&proj_id).unwrap(); + let backend = &project.backend; + let sig = git2::Signature::now(&alice.to_string(), "anonymous@radicle.xyz").unwrap(); + let head = git::initial_commit(backend, &sig).unwrap(); + + let head = git::commit(backend, &head, "Second commit", &alice.to_string()).unwrap(); + backend + .reference( + &format!("refs/remotes/{alice}/heads/master"), + head.id(), + false, + "test", + ) + .unwrap(); + + let signed = storage.sign_refs(&project).unwrap(); + let remote = project.remote(&alice).unwrap(); + let mut unsigned = project.references(&alice).unwrap(); + + // The signed refs doesn't contain the signature ref itself. + unsigned.remove(SIGNATURE_REF).unwrap(); + + assert_eq!(remote.refs, signed); + assert_eq!(*remote.refs, unsigned); + } } diff --git a/node/src/storage/refs.rs b/node/src/storage/refs.rs new file mode 100644 index 00000000..69a8299c --- /dev/null +++ b/node/src/storage/refs.rs @@ -0,0 +1,318 @@ +use std::collections::BTreeMap; +use std::fmt::Debug; +use std::io; +use std::io::{BufRead, BufReader}; +use std::marker::PhantomData; +use std::ops::{Deref, DerefMut}; +use std::path::Path; +use std::str::FromStr; + +use radicle_git_ext as git_ext; +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +use crate::crypto; +use crate::crypto::{PublicKey, Signature, Signer, Unverified, Verified}; +use crate::git; +use crate::git::Oid; +use crate::storage; +use crate::storage::{ReadRepository, RemoteId, WriteRepository}; + +pub const SIGNATURE_REF: &str = "radicle/signature"; +pub const REFS_BLOB_PATH: &str = "refs"; +pub const SIGNATURE_BLOB_PATH: &str = "signature"; + +#[derive(Debug)] +pub enum Updated { + /// The computed [`Refs`] were stored as a new commit. + Updated { oid: Oid }, + /// The stored [`Refs`] were the same as the computed ones, so no new commit + /// was created. + Unchanged { oid: Oid }, +} + +#[derive(Debug, Error)] +pub enum Error { + #[error("invalid signature: {0}")] + InvalidSignature(#[from] crypto::Error), + #[error("canonical refs: {0}")] + Canonical(#[from] canonical::Error), + #[error("invalid reference")] + InvalidRef, + #[error("invalid reference: {0}")] + Ref(#[from] git::RefError), + #[error(transparent)] + Git(#[from] git2::Error), + #[error(transparent)] + GitExt(#[from] git_ext::Error), + #[error("refs were not found")] + NotFound, +} + +/// The published state of a local repository. +#[derive(Default, Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct Refs(BTreeMap); + +impl Refs { + /// Verify the given signature on these refs, and return [`SignedRefs`] on success. + pub fn verified( + self, + signer: &PublicKey, + signature: Signature, + ) -> Result, Error> { + let refs = self; + let msg = refs.canonical(); + + match signer.verify(&signature, &msg) { + Ok(()) => Ok(SignedRefs { + refs, + signature, + _verified: PhantomData, + }), + Err(e) => Err(e.into()), + } + } + + /// Sign these refs with the given signer and return [`SignedRefs`]. + pub fn signed(self, signer: S) -> Result, Error> + where + S: Signer, + { + let refs = self; + let msg = refs.canonical(); + let signature = signer.sign(&msg); + + Ok(SignedRefs { + refs, + signature, + _verified: PhantomData, + }) + } + + /// Create refs from a canonical representation. + pub 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 = name.to_owned(); + let oid = Oid::from_str(oid)?; + + refs.insert(name, oid); + } + Ok(Self(refs)) + } + + pub fn canonical(&self) -> Vec { + let mut buf = String::new(); + let refs = self.iter().filter(|(name, _)| *name != SIGNATURE_REF); + + for (name, oid) in refs { + buf.push_str(&oid.to_string()); + buf.push(' '); + buf.push_str(name); + buf.push('\n'); + } + buf.into_bytes() + } +} + +impl From for BTreeMap { + fn from(refs: Refs) -> Self { + refs.0 + } +} + +impl From> for Refs { + fn from(signed: SignedRefs) -> Self { + signed.refs + } +} + +impl From> for Refs { + fn from(refs: BTreeMap) -> Self { + Self(refs) + } +} + +impl Deref for Refs { + type Target = BTreeMap; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl DerefMut for Refs { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 + } +} + +/// Combination of [`Refs`] and a [`Signature`]. The signature is a cryptographic +/// signature over the refs. This allows us to easily verify if a set of refs +/// came from a particular user. +/// +/// The type parameter keeps track of whether the signature was [`Verified`] or +/// [`Unverified`]. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] +pub struct SignedRefs { + refs: Refs, + signature: Signature, + #[serde(skip)] + _verified: PhantomData, +} + +impl SignedRefs { + pub fn new(refs: Refs, signature: Signature) -> Self { + Self { + refs, + signature, + _verified: PhantomData, + } + } + + pub fn verified(self, signer: &PublicKey) -> Result, crypto::Error> { + let canonical = self.refs.canonical(); + + match signer.verify(&self.signature, &canonical) { + Ok(()) => Ok(SignedRefs { + refs: self.refs, + signature: self.signature, + _verified: PhantomData, + }), + Err(e) => Err(e), + } + } +} + +impl SignedRefs { + pub fn load(remote: &RemoteId, repo: &S) -> Result + where + S: ReadRepository, + { + if let Some(oid) = repo.reference_oid(remote, SIGNATURE_REF)? { + Self::load_at(oid, remote, repo) + } else { + Err(Error::NotFound) + } + } + + pub fn load_at(oid: Oid, remote: &RemoteId, repo: &S) -> Result + where + S: storage::ReadRepository, + { + let refs = repo.blob_at(oid, Path::new(REFS_BLOB_PATH))?; + let signature = repo.blob_at(oid, Path::new(SIGNATURE_BLOB_PATH))?; + let signature = crypto::Signature::try_from(signature.content())?; + + match remote.verify(&signature, refs.content()) { + Ok(()) => { + let refs = Refs::from_canonical(refs.content())?; + + Ok(Self { + refs, + signature, + _verified: PhantomData, + }) + } + Err(e) => Err(e.into()), + } + } + + /// 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, + // TODO: This should be part of the signed refs. + remote: &RemoteId, + repo: &S, + ) -> Result { + let parent: Option = repo + .reference(remote, SIGNATURE_REF)? + .map(|r| r.peel_to_commit()) + .transpose()?; + + let tree = { + let raw = repo.raw(); + let refs_blob_oid = raw.blob(&self.canonical())?; + let sig_blob_oid = raw.blob(&self.signature.to_bytes())?; + + let mut builder = raw.treebuilder(None)?; + builder.insert(REFS_BLOB_PATH, refs_blob_oid, 0o100_644)?; + builder.insert(SIGNATURE_BLOB_PATH, sig_blob_oid, 0o100_644)?; + + let oid = builder.write()?; + + raw.find_tree(oid) + }?; + + if let Some(ref parent) = parent { + if parent.tree()?.id() == tree.id() { + return Ok(Updated::Unchanged { + oid: parent.id().into(), + }); + } + } + + let sigref = format!("refs/remotes/{remote}/{SIGNATURE_REF}"); + let author = repo.raw().signature()?; + let commit = repo.raw().commit( + Some(&sigref), + &author, + &author, + &format!("Update {} for {}", SIGNATURE_REF, remote), + &tree, + &parent.iter().collect::>(), + ); + + match commit { + Ok(oid) => Ok(Updated::Updated { oid: oid.into() }), + Err(e) => match (e.class(), e.code()) { + (git2::ErrorClass::Object, git2::ErrorCode::Modified) => { + log::warn!("Concurrent modification of refs: {:?}", e); + + Err(Error::Git(e)) + } + _ => Err(e.into()), + }, + } + } + + pub fn unverified(self) -> SignedRefs { + SignedRefs { + refs: self.refs, + signature: self.signature, + _verified: PhantomData, + } + } +} + +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("invalid canonical format")] + InvalidFormat, + #[error(transparent)] + Io(#[from] io::Error), + #[error(transparent)] + Git(#[from] git2::Error), + } +} + +// TODO: Test canonical/from_canonical diff --git a/node/src/test/arbitrary.rs b/node/src/test/arbitrary.rs index 5b52040f..4b111b3c 100644 --- a/node/src/test/arbitrary.rs +++ b/node/src/test/arbitrary.rs @@ -1,15 +1,21 @@ -use std::collections::HashSet; +use std::collections::{BTreeMap, HashSet}; use std::hash::Hash; use std::ops::RangeBounds; +use quickcheck::Arbitrary; + use crate::collections::HashMap; -use crate::crypto::PublicKey; +use crate::crypto::{self, Signer}; +use crate::crypto::{PublicKey, SecretKey}; use crate::hash; -use crate::identity::{ProjId, UserId}; +use crate::identity::ProjId; use crate::storage; +use crate::storage::refs::Refs; use crate::test::storage::MockStorage; -pub fn set(range: impl RangeBounds) -> HashSet { +use super::crypto::MockSigner; + +pub fn set(range: impl RangeBounds) -> HashSet { let size = fastrand::usize(range); let mut set = HashSet::with_capacity(size); let mut g = quickcheck::Gen::new(size); @@ -20,35 +26,34 @@ pub fn set(range: impl RangeBounds) set } -pub fn gen(size: usize) -> T { +pub fn gen(size: usize) -> T { let mut gen = quickcheck::Gen::new(size); T::arbitrary(&mut gen) } -impl quickcheck::Arbitrary for storage::Remotes { +impl Arbitrary for storage::Remotes { fn arbitrary(g: &mut quickcheck::Gen) -> Self { - let remotes: HashMap> = - quickcheck::Arbitrary::arbitrary(g); + let remotes: HashMap> = + Arbitrary::arbitrary(g); storage::Remotes::new(remotes) } } -impl quickcheck::Arbitrary for MockStorage { +impl Arbitrary for MockStorage { fn arbitrary(g: &mut quickcheck::Gen) -> Self { - let inventory = quickcheck::Arbitrary::arbitrary(g); + let inventory = Arbitrary::arbitrary(g); MockStorage::new(inventory) } } -impl quickcheck::Arbitrary for storage::Remote { +impl Arbitrary for Refs { fn arbitrary(g: &mut quickcheck::Gen) -> Self { - let rng = fastrand::Rng::with_seed(u64::arbitrary(g)); - let mut refs: HashMap = HashMap::with_hasher(rng.into()); + let mut refs: BTreeMap = BTreeMap::new(); let mut bytes: [u8; 20] = [0; 20]; + // TODO: Use refs other than branch names. let names = &["master", "dev", "feature/1", "feature/2", "feature/3"]; - let id = UserId::arbitrary(g); for _ in 0..g.size().min(2) { if let Some(name) = g.choose(names) { @@ -59,25 +64,46 @@ impl quickcheck::Arbitrary for storage::Remote { refs.insert(name.to_string(), oid); } } - storage::Remote::new(id, refs) + Self::from(refs) } } -impl quickcheck::Arbitrary for ProjId { +impl Arbitrary for storage::Remote { + fn arbitrary(g: &mut quickcheck::Gen) -> Self { + let refs = Refs::arbitrary(g); + let signer = MockSigner::arbitrary(g); + let signed = refs.signed(&signer).unwrap(); + + storage::Remote::new(*signer.public_key(), signed) + } +} + +impl Arbitrary for MockSigner { + fn arbitrary(g: &mut quickcheck::Gen) -> Self { + let mut bytes: [u8; 32] = [0; 32]; + + for byte in &mut bytes { + *byte = u8::arbitrary(g); + } + MockSigner::from(SecretKey::from(bytes)) + } +} + +impl Arbitrary for ProjId { fn arbitrary(g: &mut quickcheck::Gen) -> Self { let digest = hash::Digest::arbitrary(g); ProjId::from(digest) } } -impl quickcheck::Arbitrary for hash::Digest { +impl Arbitrary for hash::Digest { fn arbitrary(g: &mut quickcheck::Gen) -> Self { - let bytes: Vec = quickcheck::Arbitrary::arbitrary(g); + let bytes: Vec = Arbitrary::arbitrary(g); hash::Digest::new(&bytes) } } -impl quickcheck::Arbitrary for PublicKey { +impl Arbitrary for PublicKey { fn arbitrary(g: &mut quickcheck::Gen) -> Self { use ed25519_consensus::SigningKey; diff --git a/node/src/test/crypto.rs b/node/src/test/crypto.rs index 0f624082..7b8e735f 100644 --- a/node/src/test/crypto.rs +++ b/node/src/test/crypto.rs @@ -2,7 +2,7 @@ use ed25519_consensus as ed25519; use crate::crypto::{PublicKey, SecretKey, Signer}; -#[derive(Debug)] +#[derive(Debug, Clone)] pub struct MockSigner { pk: PublicKey, sk: SecretKey, @@ -15,12 +15,14 @@ impl MockSigner { for byte in &mut bytes { *byte = rng.u8(..); } - let sk = SecretKey::from(bytes); + Self::from(SecretKey::from(bytes)) + } +} - Self { - pk: sk.verification_key().into(), - sk, - } +impl From for MockSigner { + fn from(sk: SecretKey) -> Self { + let pk = sk.verification_key().into(); + Self { sk, pk } } } @@ -36,6 +38,20 @@ impl Default for MockSigner { } } +impl PartialEq for MockSigner { + fn eq(&self, other: &Self) -> bool { + self.pk == other.pk + } +} + +impl Eq for MockSigner {} + +impl std::hash::Hash for MockSigner { + fn hash(&self, state: &mut H) { + self.pk.hash(state) + } +} + impl Signer for MockSigner { fn public_key(&self) -> &PublicKey { &self.pk diff --git a/node/src/test/fixtures.rs b/node/src/test/fixtures.rs index a9601a43..94aa4092 100644 --- a/node/src/test/fixtures.rs +++ b/node/src/test/fixtures.rs @@ -1,59 +1,66 @@ use std::path::Path; use crate::git; -use crate::identity::{ProjId, UserId}; +use crate::identity::ProjId; use crate::storage::git::Storage; -use crate::storage::WriteStorage; +use crate::storage::{ReadStorage, WriteStorage}; use crate::test::arbitrary; use crate::test::crypto::MockSigner; pub fn storage>(path: P) -> Storage { let path = path.as_ref(); - let storage = Storage::open(path, MockSigner::default()).unwrap(); let proj_ids = arbitrary::set::(3..5); - let user_ids = arbitrary::set::(1..3); + let signers = arbitrary::set::(1..3); + let mut storages = signers + .into_iter() + .map(|s| Storage::open(path, s).unwrap()) + .collect::>(); crate::test::logger::init(log::Level::Debug); - for proj in proj_ids.iter() { - log::debug!("creating {}...", proj); - let repo = storage.repository(proj).unwrap(); + for storage in &storages { + let remote = storage.user_id(); - for user in user_ids.iter() { - let repo = &repo.backend; - let sig = git2::Signature::now(&user.to_string(), "anonymous@radicle.xyz").unwrap(); - let head = git::initial_commit(repo, &sig).unwrap(); + log::debug!("signer {}...", remote); - log::debug!("{}: creating {}...", proj, user); + for proj in proj_ids.iter() { + let repo = storage.repository(proj).unwrap(); + let raw = &repo.backend; + let sig = git2::Signature::now(&remote.to_string(), "anonymous@radicle.xyz").unwrap(); + let head = git::initial_commit(raw, &sig).unwrap(); - repo.reference( - &format!("refs/remotes/{user}/heads/radicle/id"), + log::debug!("{}: creating {}...", remote, proj); + + raw.reference( + &format!("refs/remotes/{remote}/heads/radicle/id"), head.id(), false, "test", ) .unwrap(); - let head = git::commit(repo, &head, "Second commit", &user.to_string()).unwrap(); - repo.reference( - &format!("refs/remotes/{user}/heads/master"), + let head = git::commit(raw, &head, "Second commit", &remote.to_string()).unwrap(); + raw.reference( + &format!("refs/remotes/{remote}/heads/master"), head.id(), false, "test", ) .unwrap(); - let head = git::commit(repo, &head, "Third commit", &user.to_string()).unwrap(); - repo.reference( - &format!("refs/remotes/{user}/heads/patch/3"), + let head = git::commit(raw, &head, "Third commit", &remote.to_string()).unwrap(); + raw.reference( + &format!("refs/remotes/{remote}/heads/patch/3"), head.id(), false, "test", ) .unwrap(); + + storage.sign_refs(&repo).unwrap(); } } - storage + storages.pop().unwrap() } #[cfg(test)] diff --git a/node/src/test/storage.rs b/node/src/test/storage.rs index 9920865a..c3b9e184 100644 --- a/node/src/test/storage.rs +++ b/node/src/test/storage.rs @@ -1,18 +1,19 @@ use git_url::Url; +use crate::crypto::Verified; use crate::identity::{ProjId, UserId}; +use crate::storage::refs; use crate::storage::{ - Error, Inventory, ReadRepository, ReadStorage, Remote, Remotes, Unverified, WriteRepository, - WriteStorage, + Error, Inventory, ReadRepository, ReadStorage, Remote, Remotes, WriteRepository, WriteStorage, }; #[derive(Clone, Debug)] pub struct MockStorage { - pub inventory: Vec<(ProjId, Remotes)>, + pub inventory: Vec<(ProjId, Remotes)>, } impl MockStorage { - pub fn new(inventory: Vec<(ProjId, Remotes)>) -> Self { + pub fn new(inventory: Vec<(ProjId, Remotes)>) -> Self { Self { inventory } } @@ -36,7 +37,7 @@ impl ReadStorage for MockStorage { } } - fn get(&self, proj: &ProjId) -> Result>, Error> { + fn get(&self, proj: &ProjId) -> Result>, Error> { if let Some((_, refs)) = self.inventory.iter().find(|(id, _)| id == proj) { return Ok(Some(refs.clone())); } @@ -60,6 +61,13 @@ impl WriteStorage for MockStorage { fn repository(&self, _proj: &ProjId) -> Result { Ok(MockRepository {}) } + + fn sign_refs( + &self, + _repository: &Self::Repository, + ) -> Result, Error> { + todo!() + } } pub struct MockRepository {} @@ -69,11 +77,39 @@ impl ReadRepository for MockRepository { todo!() } - fn remote(&self, _user: &UserId) -> Result, Error> { + fn remote(&self, _user: &UserId) -> Result, refs::Error> { todo!() } - fn remotes(&self) -> Result, Error> { + fn remotes(&self) -> Result, refs::Error> { + todo!() + } + + fn blob_at<'a>( + &'a self, + _oid: radicle_git_ext::Oid, + _path: &'a std::path::Path, + ) -> Result, radicle_git_ext::Error> { + todo!() + } + + fn reference( + &self, + _user: &UserId, + _reference: &str, + ) -> Result, git2::Error> { + todo!() + } + + fn reference_oid( + &self, + _user: &UserId, + _reference: &str, + ) -> Result, git2::Error> { + todo!() + } + + fn references(&self, _user: &UserId) -> Result { todo!() } } @@ -82,4 +118,8 @@ impl WriteRepository for MockRepository { fn fetch(&mut self, _url: &Url) -> Result<(), git2::Error> { Ok(()) } + + fn raw(&self) -> &git2::Repository { + todo!() + } }