mod id; use std::collections::{BTreeMap, BTreeSet}; use std::fmt; use std::marker::PhantomData; use std::ops::{Deref, Not}; use std::path::Path; use std::str::FromStr; use nonempty::NonEmpty; use once_cell::sync::Lazy; use radicle_cob::type_name::{TypeName, TypeNameParse}; use radicle_git_ext::Oid; use serde::{Deserialize, Serialize}; use thiserror::Error; use crate::canonical::formatter::CanonicalFormatter; use crate::cob::identity; use crate::crypto; use crate::crypto::{Signature, Unverified, Verified}; use crate::git; use crate::identity::{project::Project, Did}; use crate::storage; use crate::storage::{ReadRepository, RepositoryError}; pub use crypto::PublicKey; pub use id::*; /// 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("invalid delegates: {0}")] Delegates(&'static str), #[error("invalid threshold `{0}`: {1}")] Threshold(usize, &'static str), #[error("git: {0}")] GitExt(#[from] git::Error), #[error("git: {0}")] Git(#[from] git2::Error), #[error("missing identity document")] Missing, } impl DocError { /// Whether this error is caused by the document not being found. pub fn is_not_found(&self) -> bool { match self { Self::GitExt(git::Error::NotFound(_)) => true, Self::GitExt(git::Error::Git(e)) if git::is_not_found_err(e) => true, Self::Git(err) if git::is_not_found_err(err) => true, _ => false, } } } /// Identifies an identity document payload type. #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] #[serde(transparent)] pub struct PayloadId(TypeName); impl fmt::Display for PayloadId { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { self.0.fmt(f) } } impl FromStr for PayloadId { type Err = TypeNameParse; fn from_str(s: &str) -> Result { TypeName::from_str(s).map(Self) } } impl PayloadId { /// Project payload type. pub fn project() -> Self { Self( // SAFETY: We know this is valid. TypeName::from_str("xyz.radicle.project") .expect("PayloadId::project: type name is valid"), ) } } #[derive(Debug, Error)] pub enum PayloadError { #[error("json: {0}")] Json(#[from] serde_json::Error), #[error("payload '{0}' not found in identity document")] NotFound(PayloadId), } /// Payload value. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(transparent)] pub struct Payload { value: serde_json::Value, } impl Payload { /// Get a mutable reference to the JSON map, or `None` if the payload is not a map. pub fn as_object_mut( &mut self, ) -> Option<&mut serde_json::value::Map> { self.value.as_object_mut() } } 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 } } /// A verified identity document at a specific commit. #[derive(Debug, Clone, PartialEq, Eq)] pub struct DocAt { /// The commit at which this document exists. pub commit: Oid, /// The document blob at this commit. pub blob: Oid, /// The parsed document. pub doc: Doc, } impl Deref for DocAt { type Target = Doc; fn deref(&self) -> &Self::Target { &self.doc } } impl From for Doc { fn from(value: DocAt) -> Self { value.doc } } impl AsRef> for DocAt { fn as_ref(&self) -> &Doc { &self.doc } } /// Repository visibility. #[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase", tag = "type")] pub enum Visibility { /// Anyone and everyone. #[default] Public, /// Delegates plus the allowed DIDs. Private { #[serde(default, skip_serializing_if = "BTreeSet::is_empty")] allow: BTreeSet, }, } #[derive(Error, Debug)] #[error("'{0}' is not a valid visibility type")] pub struct VisibilityParseError(String); impl FromStr for Visibility { type Err = VisibilityParseError; fn from_str(s: &str) -> Result { match s { "public" => Ok(Visibility::Public), "private" => Ok(Visibility::private([])), _ => Err(VisibilityParseError(s.to_owned())), } } } impl Visibility { /// Check whether the visibility is public. pub fn is_public(&self) -> bool { matches!(self, Self::Public) } /// Check whether the visibility is private. pub fn is_private(&self) -> bool { matches!(self, Self::Private { .. }) } /// Private visibility with list of allowed DIDs beyond the repository delegates. pub fn private(allow: impl IntoIterator) -> Self { Self::Private { allow: BTreeSet::from_iter(allow), } } } /// An identity document. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct Doc { /// The payload section. pub payload: BTreeMap, /// The delegates section. pub delegates: NonEmpty, /// The signature threshold. pub threshold: usize, /// Repository visibility. #[serde(default, skip_serializing_if = "Visibility::is_public")] pub visibility: Visibility, #[serde(skip)] verified: PhantomData, } impl Doc { /// Check whether this document and the associated repository is visible to the given peer. pub fn is_visible_to(&self, peer: &PublicKey) -> bool { match &self.visibility { Visibility::Public => true, Visibility::Private { allow } => { allow.contains(&Did::from(*peer)) || self.is_delegate(peer) } } } /// Validate signature using this document's delegates, against a given document blob. pub fn verify_signature( &self, key: &PublicKey, signature: &Signature, blob: Oid, ) -> Result<(), PublicKey> { if !self.is_delegate(key) { return Err(*key); } if key.verify(blob.as_bytes(), signature).is_err() { return Err(*key); } Ok(()) } pub fn is_majority(&self, votes: usize) -> bool { votes >= self.majority() } pub fn majority(&self) -> usize { self.delegates.len() / 2 + 1 } pub fn blob_at(commit: Oid, repo: &R) -> Result { let path = Path::new("embeds").join(*PATH); repo.blob_at(commit, path.as_path()).map_err(DocError::from) } pub fn is_delegate(&self, key: &crypto::PublicKey) -> bool { self.delegates.contains(&key.into()) } } 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, 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 } pub fn rescind(&mut self, key: &crypto::PublicKey) -> Result, DocError> { let delegate = Did::from(key); let (matches, delegates) = self.delegates.iter().partition(|d| **d == delegate); match NonEmpty::from_vec(delegates) { Some(delegates) => { self.delegates = delegates; if self.threshold > self.delegates.len() { return Err(DocError::Threshold( self.threshold, "the thresholds exceeds the new delegate count after removal", )); } Ok(matches.is_empty().not().then_some(delegate)) } None => Err(DocError::Delegates("cannot remove the last delegate")), } } /// 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, Vec, Signature), DocError> { let (oid, bytes) = self.encode()?; let sig = signer.sign(oid.as_bytes()); Ok((oid, bytes, sig)) } pub fn signature_of(&self, signer: &G) -> Result { let (_, _, sig) = self.sign(signer)?; Ok(sig) } pub fn load_at(commit: Oid, repo: &R) -> Result { let blob = Self::blob_at(commit, repo)?; let doc = Doc::from_blob(&blob)?; Ok(DocAt { commit, doc, blob: blob.id().into(), }) } pub fn from_blob(blob: &git2::Blob) -> Result { Doc::from_json(blob.content())?.verified() } pub fn init( &self, repo: &storage::git::Repository, signer: &G, ) -> Result { let cob = identity::Identity::initialize(self, repo, signer)?; let id_ref = git::refs::storage::id(signer.public_key()); let cob_ref = git::refs::storage::cob( signer.public_key(), &crate::cob::identity::TYPENAME, &cob.id, ); // Set `.../refs/rad/id` -> `.../refs/cobs/xyz.radicle.id/` repo.backend.reference_symbolic( id_ref.as_str(), cob_ref.as_str(), false, "Create `rad/id` reference to point to new identity COB", )?; Ok(*cob.id) } } impl Doc { pub fn initial(project: Project, delegate: Did, visibility: Visibility) -> Self { Self::new(project, NonEmpty::new(delegate), 1, visibility) } pub fn new( project: Project, delegates: NonEmpty, threshold: usize, visibility: Visibility, ) -> 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, visibility, verified: PhantomData, } } pub fn from_json(bytes: &[u8]) -> Result { serde_json::from_slice(bytes).map_err(DocError::from) } pub fn verified(self) -> Result, DocError> { if self.delegates.len() > MAX_DELEGATES { return Err(DocError::Delegates("number of delegates cannot exceed 255")); } if self.delegates.is_empty() { return Err(DocError::Delegates("delegate list cannot be empty")); } if self.threshold > self.delegates.len() { return Err(DocError::Threshold( self.threshold, "threshold cannot exceed number of delegates", )); } if self.threshold == 0 { return Err(DocError::Threshold( self.threshold, "threshold cannot be zero", )); } Ok(Doc { payload: self.payload, delegates: self.delegates, threshold: self.threshold, visibility: self.visibility, verified: PhantomData, }) } } #[cfg(test)] #[allow(clippy::unwrap_used)] 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 as _, RemoteId, WriteStorage as _}; 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"), fixtures::user()).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"), Visibility::default(), &delegate, &storage, ) .unwrap(); assert_eq!( delegate.public_key().to_human(), String::from("z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi") ); assert_eq!( (*id).to_string(), "d96f425412c9f8ad5d9a9a05c9831d0728e2338d" ); assert_eq!(id.urn(), String::from("rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji")); } #[test] fn test_not_found() { let tempdir = tempfile::tempdir().unwrap(); let storage = Storage::open(tempdir.path().join("storage"), fixtures::user()).unwrap(); let remote = arbitrary::gen::(1); let proj = arbitrary::gen::(1); let repo = storage.create(proj).unwrap(); let oid = git2::Oid::from_str("2d52a53ce5e4f141148a5f770cfd3ead2d6a45b8").unwrap(); let err = repo.identity_head_of(&remote).unwrap_err(); matches!(err, git::ext::Error::NotFound(_)); let err = Doc::::load_at(oid.into(), &repo).unwrap_err(); assert!(err.is_not_found()); } #[test] fn test_canonical_doc() { let tempdir = tempfile::tempdir().unwrap(); let storage = Storage::open(tempdir.path().join("storage"), fixtures::user()).unwrap(); transport::local::register(storage.clone()); let (working, _) = fixtures::repository(tempdir.path().join("working")); let delegate = MockSigner::from_seed([0xff; 32]); let (rid, doc, _) = rad::init( &working, "heartwood", "Radicle Heartwood Protocol & Stack", git::refname!("master"), Visibility::default(), &delegate, &storage, ) .unwrap(); let repo = storage.repository(rid).unwrap(); assert_eq!(doc, repo.identity_doc().unwrap().doc); } #[quickcheck] fn prop_encode_decode(doc: Doc) { let (_, bytes) = doc.encode().unwrap(); assert_eq!(Doc::from_json(&bytes).unwrap().verified().unwrap(), doc); } #[test] fn test_visibility_json() { use std::str::FromStr; assert_eq!( serde_json::to_value(Visibility::Public).unwrap(), serde_json::json!({ "type": "public" }) ); assert_eq!( serde_json::to_value(Visibility::private([])).unwrap(), serde_json::json!({ "type": "private" }) ); assert_eq!( serde_json::to_value(Visibility::private([Did::from_str( "did:key:z6MksFqXN3Yhqk8pTJdUGLwATkRfQvwZXPqR2qMEhbS9wzpT" ) .unwrap()])) .unwrap(), serde_json::json!({ "type": "private", "allow": ["did:key:z6MksFqXN3Yhqk8pTJdUGLwATkRfQvwZXPqR2qMEhbS9wzpT"] }) ); } }