diff --git a/node/src/crypto.rs b/node/src/crypto.rs index 6210edad..4fb83744 100644 --- a/node/src/crypto.rs +++ b/node/src/crypto.rs @@ -48,7 +48,7 @@ where } /// Cryptographic signature. -#[derive(PartialEq, Eq, Debug, Copy, Clone)] +#[derive(PartialEq, Eq, Copy, Clone)] pub struct Signature(pub ed25519::Signature); impl fmt::Display for Signature { @@ -58,6 +58,12 @@ impl fmt::Display for Signature { } } +impl fmt::Debug for Signature { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "Signature({})", self) + } +} + #[derive(Error, Debug)] pub enum SignatureError { #[error("invalid multibase string: {0}")] @@ -106,7 +112,7 @@ impl TryFrom<&[u8]> for Signature { } /// The public/verification key. -#[derive(Serialize, Deserialize, Eq, Debug, Copy, Clone)] +#[derive(Serialize, Deserialize, Eq, Copy, Clone)] #[serde(into = "String", try_from = "String")] pub struct PublicKey(pub ed25519::VerificationKey); @@ -141,6 +147,12 @@ impl From for String { } } +impl fmt::Debug for PublicKey { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "PublicKey({})", self) + } +} + impl PartialEq for PublicKey { fn eq(&self, other: &Self) -> bool { self.0 == other.0 diff --git a/node/src/git.rs b/node/src/git.rs index 6abed468..b38c560f 100644 --- a/node/src/git.rs +++ b/node/src/git.rs @@ -4,7 +4,7 @@ use std::str::FromStr; use git_ref_format as format; use crate::collections::HashMap; -use crate::identity::PublicKey; +use crate::crypto::PublicKey; use crate::storage::refs::Refs; use crate::storage::RemoteId; @@ -123,6 +123,22 @@ pub fn head(repo: &git2::Repository) -> Result { Ok(head) } +/// Write a tree with the given blob at the given path. +pub fn write_tree<'r>( + path: &Path, + bytes: &[u8], + repo: &'r git2::Repository, +) -> Result, Error> { + let blob_id = repo.blob(bytes)?; + let mut builder = repo.treebuilder(None)?; + builder.insert(path, blob_id, 0o100_644)?; + + let tree_id = builder.write()?; + let tree = repo.find_tree(tree_id)?; + + Ok(tree) +} + /// Configure a repository's radicle remote. /// /// Takes the repository in which to configure the remote, the name of the remote, the public diff --git a/node/src/identity.rs b/node/src/identity.rs index 521a6962..6d9f0bb4 100644 --- a/node/src/identity.rs +++ b/node/src/identity.rs @@ -7,7 +7,8 @@ use std::{ffi::OsString, fmt, str::FromStr}; use serde::{Deserialize, Serialize}; use thiserror::Error; -use crate::crypto::{self, Verified}; +use crate::crypto; +use crate::crypto::Verified; use crate::git; use crate::serde_ext; use crate::storage::Remotes; @@ -114,7 +115,7 @@ pub enum DidError { PublicKey(#[from] crypto::PublicKeyError), } -#[derive(Serialize, Deserialize, PartialEq, Eq, Hash, Debug, Clone)] +#[derive(Serialize, Deserialize, PartialEq, Eq, Hash, Clone)] #[serde(into = "String", try_from = "String")] pub struct Did(crypto::PublicKey); @@ -160,6 +161,20 @@ impl fmt::Display for Did { } } +impl fmt::Debug for Did { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "Did({:?})", self.to_string()) + } +} + +impl Deref for Did { + type Target = PublicKey; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + /// A stored and verified project. #[derive(Debug, Clone)] pub struct Project { @@ -173,6 +188,23 @@ pub struct Project { pub path: PathBuf, } +impl Project { + pub fn delegate(&mut self, name: String, key: crypto::PublicKey) -> bool { + self.doc.delegate(Delegate { + name, + id: Did::from(key), + }) + } +} + +impl Deref for Project { + type Target = Doc; + + fn deref(&self) -> &Self::Target { + &self.doc + } +} + #[cfg(test)] mod test { use super::*; diff --git a/node/src/identity/doc.rs b/node/src/identity/doc.rs index 7e7f73ad..75073919 100644 --- a/node/src/identity/doc.rs +++ b/node/src/identity/doc.rs @@ -1,6 +1,8 @@ -use std::collections::HashMap; +use std::collections::{BTreeMap, HashMap}; +use std::fmt::Write as _; use std::io; use std::marker::PhantomData; +use std::ops::Deref; use std::path::Path; use nonempty::NonEmpty; @@ -14,7 +16,7 @@ use crate::crypto::{Signature, Unverified, Verified}; use crate::git; use crate::identity::{Did, Id}; use crate::storage::git::trailers; -use crate::storage::{BranchName, ReadRepository, RemoteId}; +use crate::storage::{BranchName, ReadRepository, RemoteId, WriteRepository, WriteStorage}; pub use crypto::PublicKey; @@ -65,12 +67,23 @@ impl From for PublicKey { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] -pub struct Doc { +pub struct Payload { pub name: String, pub description: String, // TODO: Make optional. pub default_branch: String, // TODO: Make optional. - pub version: u32, // TODO: Remove this. - pub parent: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(transparent)] +// TODO: Restrict values. +pub struct Namespace(String); + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Doc { + #[serde(rename = "xyz.radicle.project")] + pub payload: Payload, + #[serde(flatten)] + pub extensions: BTreeMap, pub delegates: NonEmpty, pub threshold: usize, @@ -78,17 +91,100 @@ pub struct Doc { } impl Doc { - pub fn encode(&self) -> Result<(Id, Vec), Error> { + pub fn encode(&self) -> Result<(git::Oid, Vec), Error> { let mut buf = Vec::new(); let mut serializer = serde_json::Serializer::with_formatter(&mut buf, olpc_cjson::CanonicalFormatter::new()); self.serialize(&mut serializer)?; - let oid = git2::Oid::hash_object(git2::ObjectType::Blob, &buf)?; - let id = Id::from(oid); - Ok((id, buf)) + Ok((oid.into(), buf)) + } + + /// Attempt to add a new delegate to the document. Returns `true` if it wasn't there before. + pub fn delegate(&mut self, delegate: Delegate) -> bool { + if self.delegates.iter().all(|d| d.id != delegate.id) { + self.delegates.push(delegate); + return true; + } + false + } + + pub fn sign(&self, signer: G) -> Result<(git::Oid, Signature), Error> { + let (oid, bytes) = self.encode()?; + let sig = signer.sign(&bytes); + + Ok((oid, sig)) + } + + pub fn create<'r, S: WriteStorage<'r>>( + &self, + remote: &RemoteId, + msg: &str, + storage: &'r S, + ) -> Result<(Id, git::Oid, S::Repository), Error> { + // You can checkout this branch in your working copy with: + // + // git fetch rad + // git checkout -b radicle/id remotes/rad/radicle/id + // + let (doc_oid, doc) = self.encode()?; + let id = Id::from(doc_oid); + let repo = storage.repository(&id).unwrap(); + let tree = git::write_tree(*PATH, doc.as_slice(), repo.raw())?; + let oid = Doc::commit(remote, &tree, msg, &[], repo.raw())?; + + drop(tree); + + Ok((id, oid, repo)) + } + + pub fn update<'r, R: WriteRepository<'r>>( + &self, + remote: &RemoteId, + msg: &str, + signatures: &[(&PublicKey, Signature)], + repo: &R, + ) -> Result { + let mut msg = format!("{msg}\n\n"); + for (key, sig) in signatures { + writeln!(&mut msg, "{}: {key} {sig}", trailers::SIGNATURE_TRAILER) + .expect("in-memory writes don't fail"); + } + + let (_, doc) = self.encode()?; + let tree = git::write_tree(*PATH, doc.as_slice(), repo.raw())?; + let id_ref = format!("refs/remotes/{remote}/{}", &*REFERENCE_NAME); + let head = repo.raw().find_reference(&id_ref)?.peel_to_commit()?; + let oid = Doc::commit(remote, &tree, &msg, &[&head], repo.raw())?; + + Ok(oid) + } + + fn commit( + remote: &RemoteId, + tree: &git2::Tree, + msg: &str, + parents: &[&git2::Commit], + repo: &git2::Repository, + ) -> Result { + let sig = repo + .signature() + .or_else(|_| git2::Signature::now("radicle", remote.to_string().as_str()))?; + + let id_ref = format!("refs/remotes/{remote}/{}", &*REFERENCE_NAME); + let oid = repo.commit(Some(&id_ref), &sig, &sig, msg, tree, parents)?; + + Ok(oid.into()) + } +} + +impl Deref for Doc { + type Target = Payload; + + fn deref(&self) -> &Self::Target { + &self.payload } } @@ -118,11 +214,12 @@ impl Doc { delegate: Delegate, ) -> Self { Self { - name, - description, - default_branch, - version: 1, - parent: None, + payload: Payload { + name, + description, + default_branch, + }, + extensions: BTreeMap::new(), delegates: NonEmpty::new(delegate), threshold: 1, verified: PhantomData, @@ -133,16 +230,16 @@ impl Doc { name: String, description: String, default_branch: BranchName, - parent: Option, delegates: NonEmpty, threshold: usize, ) -> Self { Self { - name, - description, - default_branch, - version: 1, - parent, + payload: Payload { + name, + description, + default_branch, + }, + extensions: BTreeMap::new(), delegates, threshold, verified: PhantomData, @@ -194,14 +291,6 @@ impl Doc { "default branch cannot exceed 255 bytes", )); } - if let Some(parent) = self.parent { - if parent.is_zero() { - return Err(VerificationError::Parent("parent cannot be zero")); - } - } - if self.version != 1 { - return Err(VerificationError::Version(self.version)); - } if self.threshold > self.delegates.len() { return Err(VerificationError::Threshold( self.threshold, @@ -216,12 +305,9 @@ impl Doc { } Ok(Doc { - name: self.name, - description: self.description, + payload: self.payload, + extensions: self.extensions, delegates: self.delegates, - default_branch: self.default_branch, - parent: self.parent, - version: self.version, threshold: self.threshold, verified: PhantomData, }) @@ -274,6 +360,22 @@ impl Doc { } } +#[derive(Error, Debug)] +pub enum IdentityError { + #[error("git: {0}")] + GitRaw(#[from] git2::Error), + #[error("git: {0}")] + Git(#[from] git::Error), + #[error("verification: {0}")] + Verification(#[from] VerificationError), + #[error("root hash `{0}` does not match repository")] + MismatchedRoot(Id), + #[error("commit signature for {0} is invalid: {1}")] + InvalidSignature(PublicKey, crypto::Error), + #[error("quorum not reached: {0} signatures for a threshold of {1}")] + QuorumNotReached(usize, usize), +} + #[derive(Clone, Debug, PartialEq, Eq)] pub struct Identity { /// The head of the identity branch. This points to a commit that @@ -284,6 +386,8 @@ pub struct Identity { pub root: Id, /// The object id of the current document blob. pub current: Oid, + /// Revision number. The initial document has a revision of `0`. + pub revision: u32, /// The current document. pub doc: Doc, /// Signatures over this identity. @@ -297,7 +401,7 @@ impl Identity { id: &Id, remote: &RemoteId, repo: &R, - ) -> Result, Error> { + ) -> Result>, IdentityError> { if let Some(head) = Doc::::head(remote, repo)? { let mut history = repo.revwalk(head)?.collect::>(); @@ -306,10 +410,11 @@ impl Identity { let root_blob = Doc::blob_at(root_oid, repo)?.unwrap(); let root = Id::from(root_blob.id()); let trusted = Doc::from_json(root_blob.content()).unwrap(); + let revision = history.len() as u32; // The root hash must be equal to the id. if root != *id { - todo!(); + return Err(IdentityError::MismatchedRoot(root)); } let mut trusted = trusted.verified()?; @@ -319,7 +424,7 @@ impl Identity { // Traverse the history chronologically. for oid in history.into_iter().rev() { let oid = oid?; - let blob = Doc::blob_at(head, repo)?.unwrap(); + let blob = Doc::blob_at(oid.into(), repo)?.unwrap(); let untrusted = Doc::from_json(blob.content()).unwrap(); let untrusted = untrusted.verified()?; let commit = repo.commit(oid.into())?.unwrap(); @@ -328,8 +433,8 @@ impl Identity { // Keys that signed the *current* document version. signatures = trailers::parse_signatures(msg).unwrap(); for (pk, sig) in &signatures { - if pk.verify(sig, blob.content()).is_err() { - todo!(); + if let Err(err) = pk.verify(sig, blob.content()) { + return Err(IdentityError::InvalidSignature(*pk, err)); } } @@ -338,19 +443,19 @@ impl Identity { .iter() .filter(|(key, _)| trusted.delegates.iter().any(|d| d.matches(key))) .count(); - // TODO: Check that difference isn't greater than threshold? if quorum < trusted.threshold { - todo!(); + return Err(IdentityError::QuorumNotReached(quorum, trusted.threshold)); } trusted = untrusted; current = blob.id().into(); } - return Ok(Some(Self { + return Ok(Some(Identity { root, head, current, + revision, doc: trusted, signatures: signatures.into_iter().collect(), verified: PhantomData, @@ -362,9 +467,112 @@ impl Identity { #[cfg(test)] mod test { + use crate::prelude::Signer; + use crate::rad; + use crate::storage::git::Storage; + use crate::storage::{ReadStorage, WriteStorage}; + use crate::test::{crypto, fixtures}; + use super::*; use quickcheck_macros::quickcheck; + #[test] + fn test_valid_identity() { + let tempdir = tempfile::tempdir().unwrap(); + let mut rng = fastrand::Rng::new(); + + let alice = crypto::MockSigner::new(&mut rng); + let bob = crypto::MockSigner::new(&mut rng); + let eve = crypto::MockSigner::new(&mut rng); + + let storage = Storage::open(tempdir.path().join("storage")).unwrap(); + let (id, _, _, _) = + fixtures::project(tempdir.path().join("copy"), &storage, &alice).unwrap(); + + // Bob and Eve fork the project from Alice. + rad::fork(&id, alice.public_key(), &bob, &storage).unwrap(); + rad::fork(&id, alice.public_key(), &eve, &storage).unwrap(); + + // TODO: In some cases we want to get the repo and the project, but don't + // want to have to create a repository object twice. Perhaps there should + // be a way of getting a project from a repo. + let mut proj = storage.get(alice.public_key(), &id).unwrap().unwrap(); + let repo = storage.repository(&id).unwrap(); + + // Make a change to the description and sign it. + proj.doc.payload.description += "!"; + proj.sign(&alice) + .and_then(|(_, sig)| { + proj.update( + alice.public_key(), + "Update description", + &[(alice.public_key(), sig)], + &repo, + ) + }) + .unwrap(); + + // Add Bob as a delegate, and sign it. + proj.delegate("bob".to_owned(), *bob.public_key()); + proj.doc.threshold = 2; + proj.sign(&alice) + .and_then(|(_, sig)| { + proj.update( + alice.public_key(), + "Add bob", + &[(alice.public_key(), sig)], + &repo, + ) + }) + .unwrap(); + + // Add Eve as a delegate, and sign it. + proj.delegate("eve".to_owned(), *eve.public_key()); + proj.sign(&alice) + .and_then(|(_, alice_sig)| { + proj.sign(&bob).and_then(|(_, bob_sig)| { + proj.update( + alice.public_key(), + "Add eve", + &[(alice.public_key(), alice_sig), (bob.public_key(), bob_sig)], + &repo, + ) + }) + }) + .unwrap(); + + // Update description again with signatures by Eve and Bob. + proj.doc.payload.description += "?"; + let (current, head) = proj + .sign(&bob) + .and_then(|(_, bob_sig)| { + proj.sign(&eve).and_then(|(blob_id, eve_sig)| { + proj.update( + alice.public_key(), + "Update description", + &[(bob.public_key(), bob_sig), (eve.public_key(), eve_sig)], + &repo, + ) + .map(|head| (blob_id, head)) + }) + }) + .unwrap(); + + let identity = Identity::load(&id, alice.public_key(), &repo) + .unwrap() + .unwrap(); + + assert_eq!(identity.signatures.len(), 2); + assert_eq!(identity.revision, 4); + assert_eq!(identity.root, id); + assert_eq!(identity.current, current); + assert_eq!(identity.head, head); + assert_eq!(identity.doc, proj.doc); + + let proj = storage.get(alice.public_key(), &id).unwrap().unwrap(); + assert_eq!(proj.description, "Acme's repository!?"); + } + #[quickcheck] fn prop_encode_decode(doc: Doc) { let (_, bytes) = doc.encode().unwrap(); diff --git a/node/src/rad.rs b/node/src/rad.rs index 3ec53968..f8a45549 100644 --- a/node/src/rad.rs +++ b/node/src/rad.rs @@ -39,7 +39,7 @@ pub fn init<'r, G: Signer, S: storage::WriteStorage<'r>>( description: &str, default_branch: BranchName, signer: G, - storage: S, + storage: &'r S, ) -> Result<(Id, SignedRefs), InitError> { let pk = signer.public_key(); let delegate = identity::Delegate { @@ -47,46 +47,16 @@ pub fn init<'r, G: Signer, S: storage::WriteStorage<'r>>( name: String::from("anonymous"), id: identity::Did::from(*pk), }; - let (id, doc) = identity::Doc::initial( + let doc = identity::Doc::initial( name.to_owned(), description.to_owned(), default_branch.clone(), delegate, ) - .verified()? - .encode()?; + .verified()?; - let project = storage.repository(&id)?; + let (id, _, project) = doc.create(pk, "Initialize Radicle", storage)?; - { - // Within this scope, redefine `repo` to refer to the project storage, - // since we're going to create the identity file there, and not in there - // working copy. - // - // You can checkout this branch in your working copy with: - // - // git fetch rad - // git checkout -b radicle/id remotes/rad/radicle/id - // - let filename = *identity::doc::PATH; - let repo = project.raw(); - let tree = { - let doc_blob = repo.blob(&doc)?; - let mut builder = repo.treebuilder(None)?; - builder.insert(filename, doc_blob, 0o100_644)?; - - assert_eq!(git::Oid::from(doc_blob), *id); - - let tree_id = builder.write()?; - repo.find_tree(tree_id) - }?; - let sig = repo - .signature() - .or_else(|_| git2::Signature::now("radicle", pk.to_string().as_str()))?; - - let id_ref = format!("refs/remotes/{pk}/{}", &*identity::doc::REFERENCE_NAME); - let _oid = repo.commit(Some(&id_ref), &sig, &sig, "Initialize Radicle", &tree, &[])?; - } git::set_upstream( repo, REMOTE_NAME, @@ -242,7 +212,7 @@ mod tests { let tempdir = tempfile::tempdir().unwrap(); let signer = crypto::MockSigner::default(); let public_key = *signer.public_key(); - let mut storage = Storage::open(tempdir.path().join("storage")).unwrap(); + let storage = Storage::open(tempdir.path().join("storage")).unwrap(); let (repo, _) = fixtures::repository(tempdir.path().join("working")); let (proj, refs) = init( @@ -251,7 +221,7 @@ mod tests { "Acme's repo", BranchName::from("master"), &signer, - &mut storage, + &storage, ) .unwrap(); @@ -279,7 +249,7 @@ mod tests { let alice_id = alice.public_key(); let bob = crypto::MockSigner::new(&mut rng); let bob_id = bob.public_key(); - let mut storage = Storage::open(tempdir.path().join("storage")).unwrap(); + let storage = Storage::open(tempdir.path().join("storage")).unwrap(); let (original, _) = fixtures::repository(tempdir.path().join("original")); // Alice creates a project. @@ -289,12 +259,12 @@ mod tests { "Acme's repo", BranchName::from("master"), &alice, - &mut storage, + &storage, ) .unwrap(); // Bob forks it and creates a checkout. - fork(&id, alice_id, &bob, &mut storage).unwrap(); + fork(&id, alice_id, &bob, &storage).unwrap(); checkout(&id, bob_id, tempdir.path().join("copy"), &storage).unwrap(); let bob_remote = storage.repository(&id).unwrap().remote(bob_id).unwrap(); @@ -310,7 +280,7 @@ mod tests { let tempdir = tempfile::tempdir().unwrap(); let signer = crypto::MockSigner::default(); let remote_id = signer.public_key(); - let mut storage = Storage::open(tempdir.path().join("storage")).unwrap(); + let storage = Storage::open(tempdir.path().join("storage")).unwrap(); let (original, _) = fixtures::repository(tempdir.path().join("original")); let (id, _) = init( @@ -319,7 +289,7 @@ mod tests { "Acme's repo", BranchName::from("master"), &signer, - &mut storage, + &storage, ) .unwrap(); diff --git a/node/src/storage.rs b/node/src/storage.rs index cdc7d302..8901ec8c 100644 --- a/node/src/storage.rs +++ b/node/src/storage.rs @@ -252,6 +252,8 @@ pub trait ReadRepository<'r> { fn references(&self, remote: &RemoteId) -> Result; fn remote(&self, remote: &RemoteId) -> Result, refs::Error>; fn remotes(&'r self) -> Result; + /// Return the project associated with this repository. + fn project(&self) -> Result; } pub trait WriteRepository<'r>: ReadRepository<'r> { diff --git a/node/src/storage/git.rs b/node/src/storage/git.rs index e5a02cb7..6c724ac8 100644 --- a/node/src/storage/git.rs +++ b/node/src/storage/git.rs @@ -363,6 +363,10 @@ impl<'r> ReadRepository<'r> for Repository { Ok(Box::new(iter)) } + + fn project(&self) -> Result { + todo!() + } } impl<'r> WriteRepository<'r> for Repository { diff --git a/node/src/test/arbitrary.rs b/node/src/test/arbitrary.rs index efea335c..759e6b9b 100644 --- a/node/src/test/arbitrary.rs +++ b/node/src/test/arbitrary.rs @@ -179,7 +179,8 @@ impl Arbitrary for MockStorage { impl Arbitrary for Project { fn arbitrary(g: &mut quickcheck::Gen) -> Self { let doc = Doc::::arbitrary(g); - let (id, _) = doc.encode().unwrap(); + let (oid, _) = doc.encode().unwrap(); + let id = Id::from(oid); let remotes = storage::Remotes::arbitrary(g); let path = PathBuf::arbitrary(g); @@ -230,7 +231,6 @@ impl Arbitrary for Doc { let default_branch = iter::repeat_with(|| rng.alphanumeric()) .take(rng.usize(1..16)) .collect(); - let parent = None; let delegates: NonEmpty<_> = iter::repeat_with(|| Delegate { name: iter::repeat_with(|| rng.alphanumeric()) .take(rng.usize(1..16)) @@ -242,14 +242,8 @@ impl Arbitrary for Doc { .try_into() .unwrap(); let threshold = delegates.len() / 2 + 1; - let doc: Doc = Doc::new( - name, - description, - default_branch, - parent, - delegates, - threshold, - ); + let doc: Doc = + Doc::new(name, description, default_branch, delegates, threshold); doc.verified().unwrap() } diff --git a/node/src/test/fixtures.rs b/node/src/test/fixtures.rs index da7bd1f8..6dc8c209 100644 --- a/node/src/test/fixtures.rs +++ b/node/src/test/fixtures.rs @@ -66,7 +66,7 @@ pub fn storage>(path: P) -> Storage { /// Create a new repository at the given path, and initialize it into a project. pub fn project<'r, P: AsRef, S: WriteStorage<'r>, G: Signer>( path: P, - storage: S, + storage: &'r S, signer: G, ) -> Result<(Id, SignedRefs, git2::Repository, git2::Oid), rad::InitError> { let (repo, head) = repository(path); diff --git a/node/src/test/storage.rs b/node/src/test/storage.rs index d97ed283..fe0a996a 100644 --- a/node/src/test/storage.rs +++ b/node/src/test/storage.rs @@ -125,6 +125,10 @@ impl ReadRepository<'_> for MockRepository { fn references(&self, _remote: &RemoteId) -> Result { todo!() } + + fn project(&self) -> Result { + todo!() + } } impl WriteRepository<'_> for MockRepository {