mod id; use std::collections::{BTreeMap, HashMap}; use std::fmt::{self, Write as _}; use std::marker::PhantomData; use std::ops::Deref; use std::path::Path; use nonempty::NonEmpty; use once_cell::sync::Lazy; use radicle_git_ext::Oid; use serde::{Deserialize, Serialize}; use thiserror::Error; use crate::crypto; use crate::crypto::{Signature, Unverified, Verified}; use crate::git; use crate::identity::Did; use crate::storage; use crate::storage::git::trailers; use crate::storage::{BranchName, ReadRepository, RemoteId, WriteRepository, WriteStorage}; pub use crypto::PublicKey; pub use id::*; /// Untrusted, well-formed input. #[derive(Clone, Copy, Debug)] pub struct Untrusted; /// Signed by quorum of the previous delegation. #[derive(Clone, Copy, Debug)] pub struct Trusted; /// Path to the identity document in the identity branch. pub static PATH: Lazy<&Path> = Lazy::new(|| Path::new("radicle.json")); /// Maximum length of a string in the identity document. pub const MAX_STRING_LENGTH: usize = 255; /// Maximum number of a delegates in the identity document. pub const MAX_DELEGATES: usize = 255; #[derive(Error, Debug)] pub enum DocError { #[error("json: {0}")] Json(#[from] serde_json::Error), #[error("git: {0}")] Git(#[from] git::Error), #[error("git: {0}")] RawGit(#[from] git2::Error), #[error("storage: {0}")] Storage(#[from] storage::Error), } impl DocError { /// Whether this error is caused by the document not being found. pub fn is_not_found(&self) -> bool { match self { Self::Git(git::Error::NotFound(_)) => true, Self::Git(git::Error::Git(e)) if git::is_not_found_err(e) => true, _ => false, } } } #[derive(Debug, Error)] pub enum ProjectError { #[error("invalid name: {0}")] Name(&'static str), #[error("invalid description: {0}")] Description(&'static str), #[error("invalid default branch: {0}")] DefaultBranch(&'static str), #[error("json: {0}")] Json(#[from] serde_json::Error), #[error("project payload not found in identity document")] NotFound, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct Project { pub name: String, pub description: String, pub default_branch: BranchName, } impl From for Payload { fn from(proj: Project) -> Self { let value = serde_json::to_value(proj) .expect("Payload::from: could not convert project into value"); Self { value } } } impl Project { /// Validate the project data. pub fn validate(&self) -> Result<(), ProjectError> { if self.name.is_empty() { return Err(ProjectError::Name("name cannot be empty")); } if self.name.len() > MAX_STRING_LENGTH { return Err(ProjectError::Name("name cannot exceed 255 bytes")); } if self.description.len() > MAX_STRING_LENGTH { return Err(ProjectError::Description( "description cannot exceed 255 bytes", )); } if self.default_branch.is_empty() { return Err(ProjectError::DefaultBranch( "default branch cannot be empty", )); } if self.default_branch.len() > MAX_STRING_LENGTH { return Err(ProjectError::DefaultBranch( "default branch cannot exceed 255 bytes", )); } Ok(()) } } #[derive(Debug, Error)] pub enum PayloadError { #[error("json: {0}")] Json(#[from] serde_json::Error), #[error("payload '{0}' not found in identity document")] NotFound(PayloadId), } /// Identifies an identity document payload type. #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] #[serde(transparent)] // TODO: Restrict values. pub struct PayloadId(String); impl fmt::Display for PayloadId { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { self.0.fmt(f) } } impl PayloadId where PayloadId: Clone, { /// Project payload type. pub fn project() -> Self { Self(String::from("xyz.radicle.project")) } } /// Payload value. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(transparent)] pub struct Payload { value: serde_json::Value, } impl From for Payload { fn from(value: serde_json::Value) -> Self { Self { value } } } impl Deref for Payload { type Target = serde_json::Value; fn deref(&self) -> &Self::Target { &self.value } } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct Doc { pub payload: BTreeMap, pub delegates: NonEmpty, pub threshold: usize, #[serde(skip)] verified: PhantomData, } impl Doc { pub fn encode(&self) -> Result<(git::Oid, Vec), DocError> { 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)?; 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, key: crypto::PublicKey) -> bool { let delegate = Did::from(key); if self.delegates.iter().all(|id| id != &delegate) { self.delegates.push(delegate); return true; } false } /// Get the project payload, if it exists and is valid, out of this document. pub fn project(&self) -> Result { let value = self .payload .get(&PayloadId::project()) .ok_or_else(|| PayloadError::NotFound(PayloadId::project()))?; let proj: Project = serde_json::from_value((**value).clone())?; Ok(proj) } pub fn sign(&self, signer: &G) -> Result<(git::Oid, Signature), DocError> { let (oid, bytes) = self.encode()?; let sig = signer.sign(&bytes); Ok((oid, sig)) } pub fn create( &self, remote: &RemoteId, msg: &str, storage: &S, ) -> Result<(Id, git::Oid, S::Repository), DocError> { let (doc_oid, doc) = self.encode()?; let id = Id::from(doc_oid); let repo = storage.repository(id)?; 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( &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 = git::refs::storage::id(remote); 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 = git::refs::storage::id(remote); let oid = repo.commit(Some(&id_ref), &sig, &sig, msg, tree, parents)?; Ok(oid.into()) } } #[derive(Error, Debug)] pub enum VerificationError { #[error("invalid delegates: {0}")] Delegates(&'static str), #[error("invalid version `{0}`")] Version(u32), #[error("invalid threshold `{0}`: {1}")] Threshold(usize, &'static str), } impl Doc { pub fn initial(project: Project, delegate: Did) -> Self { Self::new(project, NonEmpty::new(delegate), 1) } pub fn new(project: Project, delegates: NonEmpty, threshold: usize) -> Self { let project = serde_json::to_value(project).expect("Doc::initial: payload must be serializable"); Self { payload: BTreeMap::from_iter([(PayloadId::project(), Payload::from(project))]), delegates, threshold, verified: PhantomData, } } pub fn from_json(bytes: &[u8]) -> Result { serde_json::from_slice(bytes).map_err(DocError::from) } pub fn verified(self) -> Result, VerificationError> { if self.delegates.len() > MAX_DELEGATES { return Err(VerificationError::Delegates( "number of delegates cannot exceed 255", )); } if self.delegates.is_empty() { return Err(VerificationError::Delegates( "delegate list cannot be empty", )); } if self.threshold > self.delegates.len() { return Err(VerificationError::Threshold( self.threshold, "threshold cannot exceed number of delegates", )); } if self.threshold == 0 { return Err(VerificationError::Threshold( self.threshold, "threshold cannot be zero", )); } Ok(Doc { payload: self.payload, delegates: self.delegates, threshold: self.threshold, verified: PhantomData, }) } pub fn blob_at(commit: Oid, repo: &R) -> Result { repo.blob_at(commit, Path::new(&*PATH)) .map_err(DocError::from) } pub fn load_at(commit: Oid, repo: &R) -> Result<(Self, Oid), DocError> { let blob = Self::blob_at(commit, repo)?; let doc = Doc::from_json(blob.content())?; Ok((doc, blob.id().into())) } pub fn load(remote: &RemoteId, repo: &R) -> Result<(Self, Oid), DocError> { let oid = Self::head(remote, repo)?; Self::load_at(oid, repo) } } impl Doc { pub fn head(remote: &RemoteId, repo: &R) -> Result { repo.reference_oid(remote, &git::refs::storage::IDENTITY_BRANCH) .map_err(DocError::from) } } #[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 project")] MismatchedRoot(Oid), #[error("commit signature for {0} is invalid: {1}")] InvalidSignature(PublicKey, crypto::Error), #[error("commit message for {0} is invalid")] InvalidCommitMessage(Oid), #[error("commit trailers for {0} are invalid: {1}")] InvalidCommitTrailers(Oid, trailers::Error), #[error("quorum not reached: {0} signatures for a threshold of {1}")] QuorumNotReached(usize, usize), #[error("identity document error: {0}")] Doc(#[from] DocError), #[error("the document root is missing")] MissingRoot, } #[derive(Clone, Debug, PartialEq, Eq)] pub struct Identity { /// The head of the identity branch. This points to a commit that /// contains the current document blob. pub head: Oid, /// The canonical identifier for this identity. /// This is the object id of the initial document blob. pub root: I, /// 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. pub signatures: HashMap, } impl radicle_cob::identity::Identity for Identity { type Identifier = Oid; fn content_id(&self) -> Oid { self.current } } impl Identity { pub fn verified(self, id: Id) -> Result, IdentityError> { // The root hash must be equal to the id. if self.root != *id { return Err(IdentityError::MismatchedRoot(self.root)); } Ok(Identity { root: id, head: self.head, current: self.current, revision: self.revision, doc: self.doc, signatures: self.signatures, }) } } impl Identity { pub fn load( remote: &RemoteId, repo: &R, ) -> Result, IdentityError> { let head = Doc::::head(remote, repo)?; let mut history = repo.revwalk(head)?.collect::>(); // Retrieve root document. let root_oid = history.pop().ok_or(IdentityError::MissingRoot)??.into(); let root_blob = Doc::blob_at(root_oid, repo)?; let root: git::Oid = root_blob.id().into(); let trusted = Doc::from_json(root_blob.content())?; let revision = history.len() as u32; let mut trusted = trusted.verified()?; let mut current = root; let mut signatures = Vec::new(); // Traverse the history chronologically. for oid in history.into_iter().rev() { let oid = oid?; let blob = Doc::blob_at(oid.into(), repo)?; let untrusted = Doc::from_json(blob.content()).map_err(DocError::from)?; let untrusted = untrusted.verified()?; let commit = repo.commit(oid.into())?; let msg = commit .message_raw() .ok_or_else(|| IdentityError::InvalidCommitMessage(oid.into()))?; // Keys that signed the *current* document version. signatures = trailers::parse_signatures(msg) .map_err(|e| IdentityError::InvalidCommitTrailers(oid.into(), e))?; for (pk, sig) in &signatures { if let Err(err) = pk.verify(blob.content(), sig) { return Err(IdentityError::InvalidSignature(*pk, err)); } } // Check that enough delegates signed this next version. let quorum = signatures .iter() .filter(|(key, _)| trusted.delegates.iter().any(|d| &**d == key)) .count(); if quorum < trusted.threshold { return Err(IdentityError::QuorumNotReached(quorum, trusted.threshold)); } trusted = untrusted; current = blob.id().into(); } Ok(Identity { root, head, current, revision, doc: trusted, signatures: signatures.into_iter().collect(), }) } } #[cfg(test)] mod test { use radicle_crypto::test::signer::MockSigner; use radicle_crypto::Signer as _; use crate::rad; use crate::storage::git::transport; use crate::storage::git::Storage; use crate::storage::{ReadStorage, WriteStorage}; use crate::test::arbitrary; use crate::test::fixtures; use super::*; use qcheck_macros::quickcheck; #[test] fn test_canonical_example() { let tempdir = tempfile::tempdir().unwrap(); let storage = Storage::open(tempdir.path().join("storage")).unwrap(); transport::local::register(storage.clone()); let delegate = MockSigner::from_seed([0xff; 32]); let (repo, _) = fixtures::repository(tempdir.path().join("working")); let (id, _, _) = rad::init( &repo, "heartwood", "Radicle Heartwood Protocol & Stack", git::refname!("master"), &delegate, &storage, ) .unwrap(); assert_eq!( delegate.public_key().to_human(), String::from("z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi") ); assert_eq!( (*id).to_string(), "d96f425412c9f8ad5d9a9a05c9831d0728e2338d" ); assert_eq!( id.to_human(), String::from("rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji") ); } #[test] fn test_not_found() { let tempdir = tempfile::tempdir().unwrap(); let storage = Storage::open(tempdir.path().join("storage")).unwrap(); let remote = arbitrary::gen::(1); let proj = arbitrary::gen::(1); let repo = storage.repository(proj).unwrap(); let oid = git2::Oid::from_str("2d52a53ce5e4f141148a5f770cfd3ead2d6a45b8").unwrap(); let err = Doc::::head(&remote, &repo).unwrap_err(); assert!(err.is_not_found()); let err = Doc::load_at(oid.into(), &repo).unwrap_err(); assert!(err.is_not_found()); } #[test] fn test_valid_identity() { let tempdir = tempfile::tempdir().unwrap(); let mut rng = fastrand::Rng::new(); let alice = MockSigner::new(&mut rng); let bob = MockSigner::new(&mut rng); let eve = 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_remote(id, alice.public_key(), &bob, &storage).unwrap(); rad::fork_remote(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 doc = storage.get(alice.public_key(), id).unwrap().unwrap(); let mut prj = doc.project().unwrap(); let repo = storage.repository(id).unwrap(); // Make a change to the description and sign it. prj.description += "!"; doc.payload.insert(PayloadId::project(), prj.clone().into()); doc.sign(&alice) .and_then(|(_, sig)| { doc.update( alice.public_key(), "Update description", &[(alice.public_key(), sig)], &repo, ) }) .unwrap(); // Add Bob as a delegate, and sign it. doc.delegate(*bob.public_key()); doc.threshold = 2; doc.sign(&alice) .and_then(|(_, sig)| { doc.update( alice.public_key(), "Add bob", &[(alice.public_key(), sig)], &repo, ) }) .unwrap(); // Add Eve as a delegate, and sign it. doc.delegate(*eve.public_key()); doc.sign(&alice) .and_then(|(_, alice_sig)| { doc.sign(&bob).and_then(|(_, bob_sig)| { doc.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. prj.description += "?"; doc.payload.insert(PayloadId::project(), prj.into()); let (current, head) = doc .sign(&bob) .and_then(|(_, bob_sig)| { doc.sign(&eve).and_then(|(blob_id, eve_sig)| { doc.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 = Identity::load(alice.public_key(), &repo) .unwrap() .verified(id) .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, doc); let doc = storage.get(alice.public_key(), id).unwrap().unwrap(); assert_eq!(doc.project().unwrap().description, "Acme's repository!?"); } #[quickcheck] fn prop_encode_decode(doc: Doc) { let (_, bytes) = doc.encode().unwrap(); assert_eq!(Doc::from_json(&bytes).unwrap().verified().unwrap(), doc); } }