use std::collections::BTreeMap; use std::sync::LazyLock; use std::{fmt, ops::Deref, str::FromStr}; use crypto::{PublicKey, Signature}; use radicle_cob::{Embed, ObjectId, TypeName}; use radicle_git_ext as git_ext; use radicle_git_ext::Oid; use serde::{Deserialize, Serialize}; use thiserror::Error; use crate::identity::doc::Doc; use crate::node::device::Device; use crate::node::NodeId; use crate::storage; use crate::{ cob, cob::{ op, store, store::{Cob, CobAction, Transaction}, ActorId, Timestamp, Uri, }, identity::{ doc::{DocError, RepoId}, Did, }, storage::{ReadRepository, RepositoryError, WriteRepository}, }; use super::{Author, EntryId}; /// Type name of an identity proposal. pub static TYPENAME: LazyLock = LazyLock::new(|| FromStr::from_str("xyz.radicle.id").expect("type name is valid")); /// Identity operation. pub type Op = cob::Op; /// Identifier for an identity revision. pub type RevisionId = EntryId; pub type IdentityStream<'a> = cob::stream::Stream<'a, Action>; impl<'a> IdentityStream<'a> { pub fn init(identity: ObjectId, store: &'a storage::git::Repository) -> Self { let history = cob::stream::CobRange::new(&TYPENAME, &identity); Self::new(&store.backend, history, TYPENAME.clone()) } } /// Proposal operation. #[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)] #[serde(tag = "type")] pub enum Action { #[serde(rename = "revision")] Revision { /// Short summary of changes. title: String, /// Longer comment on proposed changes. #[serde(default, skip_serializing_if = "String::is_empty")] description: String, /// Blob identifier of the document included in this action as an embed. /// Hence, we do not include it as a parent of this action in [`CobAction`]. blob: Oid, /// Parent revision that this revision replaces. parent: Option, /// Signature over the revision blob. signature: Signature, }, RevisionEdit { /// The revision to edit. revision: RevisionId, /// Short summary of changes. title: String, /// Longer comment on proposed changes. #[serde(default, skip_serializing_if = "String::is_empty")] description: String, }, #[serde(rename = "revision.accept")] RevisionAccept { revision: RevisionId, /// Signature over the blob. signature: Signature, }, #[serde(rename = "revision.reject")] RevisionReject { revision: RevisionId }, #[serde(rename = "revision.redact")] RevisionRedact { revision: RevisionId }, } impl CobAction for Action { fn produces_identifier(&self) -> bool { matches!(self, Self::Revision { .. }) } } /// Error applying an operation onto a state. #[derive(Error, Debug)] pub enum ApplyError { /// Causal dependency missing. /// /// This error indicates that the operations are not being applied /// in causal order, which is a requirement for this CRDT. /// /// For example, this can occur if an operation references another operation /// that hasn't happened yet. #[error("causal dependency {0:?} missing")] Missing(EntryId), /// General error initializing an identity. #[error("initialization failed: {0}")] Init(&'static str), /// Invalid signature over document blob. #[error("invalid signature from {0} for blob {1}")] InvalidSignature(PublicKey, Oid), /// Unauthorized action. #[error("not authorized to perform this action")] NotAuthorized, #[error("parent id is missing from revision")] MissingParent, #[error("verdict for this revision has already been applied")] DuplicateVerdict, #[error("revision is in an unexpected state")] UnexpectedState, #[error("revision has been redacted")] Redacted, #[error("document does not contain any changes to current identity")] DocUnchanged, #[error("git: {0}")] Git(#[from] git2::Error), #[error("git: {0}")] GitExt(#[from] git_ext::Error), #[error("identity document error: {0}")] Doc(#[from] DocError), } /// Error updating or creating proposals. #[derive(Error, Debug)] pub enum Error { #[error("apply failed: {0}")] Apply(#[from] ApplyError), #[error("store: {0}")] Store(#[from] store::Error), #[error("op decoding failed: {0}")] Op(#[from] op::OpEncodingError), #[error(transparent)] Doc(#[from] DocError), #[error("revision {0} was not found")] NotFound(RevisionId), } /// An evolving identity document. #[derive(Debug, Clone, PartialEq, Eq, Serialize)] #[serde(rename_all = "camelCase")] pub struct Identity { /// The canonical identifier for this identity. /// This is the object id of the initial document blob. pub id: RepoId, /// The current revision of the document. /// Equal to the head of the identity branch. pub current: RevisionId, /// The initial revision of the document. pub root: RevisionId, /// The latest revision that each delegate has accepted. /// Delegates can only accept one revision at a time. pub heads: BTreeMap, /// Revisions. revisions: BTreeMap>, /// Timeline of events. timeline: Vec, } impl cob::store::CobWithType for Identity { fn type_name() -> &'static TypeName { &TYPENAME } } impl std::ops::Deref for Identity { type Target = Revision; fn deref(&self) -> &Self::Target { self.current() } } impl Identity { pub fn new(revision: Revision) -> Self { let root = revision.id; Self { id: revision.blob.into(), root, current: root, heads: revision .delegates() .iter() .copied() .map(|did| (did, root)) .collect(), revisions: BTreeMap::from_iter([(root, Some(revision))]), timeline: vec![root], } } pub fn initialize<'a, R, G>( doc: &Doc, store: &'a R, signer: &Device, ) -> Result, cob::store::Error> where G: crypto::signature::Signer, R: WriteRepository + cob::Store, { let mut store = cob::store::Store::open(store)?; let (id, identity) = Transaction::::initial( "Initialize identity", &mut store, signer, |tx, repo| tx.revision("Initial revision", "", doc, None, repo, signer), )?; Ok(IdentityMut { id, identity, store, }) } pub fn get( object: &ObjectId, repo: &R, ) -> Result { use cob::store::CobWithType; cob::get::(repo, Self::type_name(), object) .map(|r| r.map(|cob| cob.object))? .ok_or_else(move || store::Error::NotFound(TYPENAME.clone(), *object)) } /// Get a proposal mutably. pub fn get_mut<'a, R: WriteRepository + cob::Store>( id: &ObjectId, repo: &'a R, ) -> Result, store::Error> { let obj = Self::get(id, repo)?; let store = cob::store::Store::open(repo)?; Ok(IdentityMut { id: *id, identity: obj, store, }) } pub fn load(repo: &R) -> Result { let oid = repo.identity_root()?; let oid = ObjectId::from(oid); Self::get(&oid, repo).map_err(RepositoryError::from) } pub fn load_mut>( repo: &R, ) -> Result, RepositoryError> { let oid = repo.identity_root()?; let oid = ObjectId::from(oid); Self::get_mut(&oid, repo).map_err(RepositoryError::from) } } impl Identity { /// The repository identifier. pub fn id(&self) -> RepoId { self.id } /// The current document. pub fn doc(&self) -> &Doc { &self.current().doc } /// The current revision. pub fn current(&self) -> &Revision { self.revision(&self.current) .expect("Identity::current: the current revision must always exist") } /// The initial revision of this identity. pub fn root(&self) -> &Revision { self.revision(&self.root) .expect("Identity::root: the root revision must always exist") } /// The head of the identity branch. This points to a commit that /// contains the current document blob. pub fn head(&self) -> Oid { self.current } /// A specific [`Revision`], that may be redacted. pub fn revision(&self, revision: &RevisionId) -> Option<&Revision> { self.revisions.get(revision).and_then(|r| r.as_ref()) } /// All the [`Revision`]s that have not been redacted. pub fn revisions(&self) -> impl DoubleEndedIterator { self.timeline .iter() .filter_map(|id| self.revisions.get(id).and_then(|o| o.as_ref())) } pub fn latest_by(&self, who: &Did) -> Option<&Revision> { self.revisions().rev().find(|r| r.author.id() == who) } } impl store::Cob for Identity { type Action = Action; type Error = ApplyError; fn from_root(op: Op, repo: &R) -> Result { let mut actions = op.actions.into_iter(); let Some(Action::Revision { title, description, blob, signature, parent, }) = actions.next() else { return Err(ApplyError::Init( "the first action must be of type `revision`", )); }; if parent.is_some() { return Err(ApplyError::Init( "the initial revision must not have a parent", )); } if actions.next().is_some() { return Err(ApplyError::Init( "the first operation must contain only one action", )); } let root = Doc::load_at(op.id, repo)?; if root.blob != blob { return Err(ApplyError::Init("invalid object id specified in revision")); } if root.blob != *repo.id() { return Err(ApplyError::Init( "repository root does not match identifier", )); } assert_eq!(root.commit, op.id); let founder = root.delegates().first(); if founder.as_key() != &op.author { return Err(ApplyError::Init("delegate does not match committer")); } // Verify signature against root document. Since there is no previous document, // we verify it against itself. if root .verify_signature(founder, &signature, root.blob) .is_err() { return Err(ApplyError::InvalidSignature(**founder, root.blob)); } let revision = Revision::new( root.commit, title, description, op.author.into(), root.blob, root.doc, State::Accepted, signature, parent, op.timestamp, ); Ok(Identity::new(revision)) } fn op<'a, R: ReadRepository, I: IntoIterator>( &mut self, op: Op, concurrent: I, repo: &R, ) -> Result<(), ApplyError> { let id = op.id; let concurrent = concurrent.into_iter().collect::>(); for action in op.actions { match self.action(action, id, op.author, op.timestamp, &concurrent, repo) { Ok(()) => {} // This particular error is returned when there is a mismatch between the expected // and the actual state of a revision, which can happen concurrently. Therefore // if there are other concurrent ops, it is not fatal and we simply ignore it. Err(ApplyError::UnexpectedState) => { if concurrent.is_empty() { return Err(ApplyError::UnexpectedState); } } // It's not a user error if the revision happens to be redacted by // the time this action is processed. Err(ApplyError::Redacted) => {} Err(other) => return Err(other), } debug_assert!(!self.timeline.contains(&id)); self.timeline.push(id); } Ok(()) } } impl Identity { /// Apply a single action to the identity document. /// /// This function ensures a few things: /// * Only delegates can interact with the state. /// * There is only ever one accepted revision; this is the "current" revision. /// * There can be zero or more active revisions, up to the number of delegates. /// * An active revision is one that can be "voted" on. /// * An active revision always has the current revision as parent. /// * Only the active revision can be accepted, rejected or edited. fn action( &mut self, action: Action, entry: EntryId, author: ActorId, timestamp: Timestamp, _concurrent: &[&cob::Entry], repo: &R, ) -> Result<(), ApplyError> { let current = self.current().clone(); if !current.is_delegate(&author.into()) { return Err(ApplyError::UnexpectedState); } match action { Action::RevisionAccept { revision, signature, } => { let id = revision; let Some(revision) = lookup::revision_mut(&mut self.revisions, &id)? else { return Err(ApplyError::Redacted); }; if !revision.is_active() { // You can't vote on an inactive revision. return Err(ApplyError::UnexpectedState); } assert_eq!(revision.parent, Some(current.id)); self.heads.insert(author.into(), id); revision.accept(author, signature, ¤t)?; self.adopt(id); } Action::RevisionReject { revision } => { let Some(revision) = lookup::revision_mut(&mut self.revisions, &revision)? else { return Err(ApplyError::Redacted); }; if !revision.is_active() { // You can't vote on an inactive revision. return Err(ApplyError::UnexpectedState); } assert_eq!(revision.parent, Some(current.id)); revision.reject(author)?; } Action::RevisionEdit { title, description, revision, } => { if revision == self.current { return Err(ApplyError::NotAuthorized); } let Some(revision) = lookup::revision_mut(&mut self.revisions, &revision)? else { return Err(ApplyError::Redacted); }; if !revision.is_active() { // You can't edit an inactive revision. return Err(ApplyError::UnexpectedState); } if revision.author.public_key() != &author { // Can't edit someone else's revision. // Since the author never changes, we can safely mark this as invalid. return Err(ApplyError::NotAuthorized); } assert_eq!(revision.parent, Some(current.id)); revision.title = title; revision.description = description; } Action::RevisionRedact { revision } => { if revision == self.current { // Can't redact the current revision. return Err(ApplyError::UnexpectedState); } if let Some(revision) = self.revisions.get_mut(&revision) { if let Some(r) = revision { if r.is_accepted() { // You can't redact an accepted revision. return Err(ApplyError::UnexpectedState); } if r.author.public_key() != &author { // Can't redact someone else's revision. // Since the author never changes, we can safely mark this as invalid. return Err(ApplyError::NotAuthorized); } *revision = None; } } else { return Err(ApplyError::Missing(revision)); } } Action::Revision { title, description, blob, signature, parent, } => { debug_assert!(!self.revisions.contains_key(&entry)); let doc = repo.blob(blob)?; let doc = Doc::from_blob(&doc)?; // All revisions but the first one must have a parent. let Some(parent) = parent else { return Err(ApplyError::MissingParent); }; let Some(parent) = lookup::revision(&self.revisions, &parent)? else { return Err(ApplyError::Redacted); }; // If the parent of this revision is no longer the current document, this // revision can be marked as outdated. let state = if parent.id == current.id { // If the revision is not outdated, we expect it to make a change to the // current version. if doc == parent.doc { return Err(ApplyError::DocUnchanged); } State::Active } else { State::Stale }; // Verify signature over new blob, using trusted delegates. if parent.verify_signature(&author, &signature, blob).is_err() { return Err(ApplyError::InvalidSignature(author, blob)); } let revision = Revision::new( entry, title, description, author.into(), blob, doc, state, signature, Some(parent.id), timestamp, ); let id = revision.id; self.heads.insert(author.into(), id); self.revisions.insert(id, Some(revision)); if state == State::Active { self.adopt(id); } } } Ok(()) } /// Try to adopt a revision as the current one. fn adopt(&mut self, id: RevisionId) { if self.current == id { return; } let votes = self .heads .values() .filter(|revision| **revision == id) .count(); if self.is_majority(votes) { self.current = id; self.current_mut().state = State::Accepted; // Void all other active revisions. for r in self .revisions .iter_mut() .filter_map(|(_, r)| r.as_mut()) .filter(|r| r.state == State::Active) { r.state = State::Stale; } } } /// A specific [`Revision`], mutably. fn revision_mut(&mut self, revision: &RevisionId) -> Option<&mut Revision> { self.revisions.get_mut(revision).and_then(|r| r.as_mut()) } /// The current revision, mutably. fn current_mut(&mut self) -> &mut Revision { let current = self.current; self.revision_mut(¤t) .expect("Identity::current_mut: the current revision must always exist") } } impl cob::Evaluate for Identity { type Error = Error; fn init(entry: &cob::Entry, repo: &R) -> Result { let op = Op::try_from(entry)?; let object = Identity::from_root(op, repo)?; Ok(object) } fn apply<'a, I: Iterator>( &mut self, entry: &cob::Entry, concurrent: I, repo: &R, ) -> Result<(), Self::Error> { let op = Op::try_from(entry)?; self.op(op, concurrent.map(|(_, e)| e), repo) .map_err(Error::Apply) } } #[derive(Clone, Debug, PartialEq, Eq, Serialize)] pub enum Verdict { /// An accepting verdict must supply the [`Signature`] over the /// new proposed [`Doc`]. Accept(Signature), /// Rejecting the proposed [`Doc`]. Reject, } /// State of a revision. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub enum State { /// The revision is actively being voted on. From here, it can go into any of the /// other states. Active, /// The revision has been accepted by a majority of delegates. Once accepted, /// a revision doesn't change state. Accepted, /// The revision was rejected by a majority of delegates. Once rejected, /// a revision doesn't change state. Rejected, /// The revision was active, but has been replaced by another revision, /// and is now outdated. Once stale, a revision doesn't change state. Stale, } impl std::fmt::Display for State { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::Active => write!(f, "active"), Self::Accepted => write!(f, "accepted"), Self::Rejected => write!(f, "rejected"), Self::Stale => write!(f, "stale"), } } } /// A new [`Doc`] for an [`Identity`]. The revision can be /// reviewed by gathering [`Signature`]s for accepting the changes, or /// rejecting them. /// /// Once a revision has reached the quorum threshold of the previous /// [`Identity`] it is then adopted as the current identity. #[derive(Clone, Debug, PartialEq, Eq, Serialize)] pub struct Revision { /// The id of this revision. Points to a commit. pub id: RevisionId, /// Identity document blob at this revision. pub blob: Oid, /// Title of the proposal. pub title: String, /// State of the revision. pub state: State, /// Description of the proposal. pub description: String, /// Author of this proposed revision. pub author: Author, /// New [`Doc`] that will replace `previous`' document. pub doc: Doc, /// Physical timestamp of this proposal revision. pub timestamp: Timestamp, /// Parent revision. pub parent: Option, /// Signatures and rejections given by the delegates. verdicts: BTreeMap, } impl std::ops::Deref for Revision { type Target = Doc; fn deref(&self) -> &Self::Target { &self.doc } } impl Revision { pub fn signatures(&self) -> impl Iterator { self.verdicts().filter_map(|(key, verdict)| match verdict { Verdict::Accept(sig) => Some((key, *sig)), Verdict::Reject => None, }) } pub fn is_accepted(&self) -> bool { matches!(self.state, State::Accepted) } pub fn is_active(&self) -> bool { matches!(self.state, State::Active) } pub fn verdicts(&self) -> impl Iterator { self.verdicts.iter() } pub fn accepted(&self) -> impl Iterator + '_ { self.signatures().map(|(key, _)| key.into()) } pub fn rejected(&self) -> impl Iterator + '_ { self.verdicts().filter_map(|(key, v)| match v { Verdict::Accept(_) => None, Verdict::Reject => Some(key.into()), }) } pub fn sign>( &self, signer: &G, ) -> Result { self.doc.signature_of(signer) } } // Private functions that may not do all the verification. Use with caution. impl Revision { fn new( id: RevisionId, title: String, description: String, author: Author, blob: Oid, doc: Doc, state: State, signature: Signature, parent: Option, timestamp: Timestamp, ) -> Self { let verdicts = BTreeMap::from_iter([(*author.public_key(), Verdict::Accept(signature))]); Self { id, title, description, author, blob, doc, state, verdicts, parent, timestamp, } } fn accept( &mut self, author: PublicKey, signature: Signature, current: &Revision, ) -> Result<(), ApplyError> { // Check that this is a valid signature over the new document blob id. if current .verify_signature(&author, &signature, self.blob) .is_err() { return Err(ApplyError::InvalidSignature(author, self.blob)); } if self .verdicts .insert(author, Verdict::Accept(signature)) .is_some() { return Err(ApplyError::DuplicateVerdict); } Ok(()) } fn reject(&mut self, key: PublicKey) -> Result<(), ApplyError> { if self.verdicts.insert(key, Verdict::Reject).is_some() { return Err(ApplyError::DuplicateVerdict); } // Mark as rejected if it's impossible for this revision to be accepted // with the current delegate set. Note that if the delegate set changes, // this proposal will be marked as `stale` anyway. if self.is_active() && self.rejected().count() > self.delegates().len() - self.majority() { self.state = State::Rejected; } Ok(()) } } impl store::Transaction { pub fn accept( &mut self, revision: RevisionId, signature: Signature, ) -> Result<(), store::Error> { self.push(Action::RevisionAccept { revision, signature, }) } pub fn reject(&mut self, revision: RevisionId) -> Result<(), store::Error> { self.push(Action::RevisionReject { revision }) } pub fn edit( &mut self, revision: RevisionId, title: impl ToString, description: impl ToString, ) -> Result<(), store::Error> { self.push(Action::RevisionEdit { revision, title: title.to_string(), description: description.to_string(), }) } pub fn redact(&mut self, revision: RevisionId) -> Result<(), store::Error> { self.push(Action::RevisionRedact { revision }) } } impl store::Transaction { pub fn revision>( &mut self, title: impl ToString, description: impl ToString, doc: &Doc, parent: Option, repo: &R, signer: &Device, ) -> Result<(), store::Error> { let (blob, bytes, signature) = doc.sign(signer).map_err(store::Error::Identity)?; // Store document blob in repository. let embed = Embed::::store("radicle.json", &bytes, repo.raw()).map_err(store::Error::Git)?; debug_assert_eq!(embed.content, Uri::from(blob)); // Make sure we pre-computed the correct OID for the blob. // Identity document. self.embed([embed])?; // Revision metadata. self.push(Action::Revision { title: title.to_string(), description: description.to_string(), blob, parent, signature, }) } } pub struct IdentityMut<'a, R> { pub id: ObjectId, identity: Identity, store: store::Store<'a, Identity, R>, } impl fmt::Debug for IdentityMut<'_, R> { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.debug_struct("IdentityMut") .field("id", &self.id) .field("identity", &self.identity) .finish() } } impl IdentityMut<'_, R> where R: WriteRepository + cob::Store, { /// Reload the identity data from storage. pub fn reload(&mut self) -> Result<(), store::Error> { self.identity = self .store .get(&self.id)? .ok_or_else(|| store::Error::NotFound(TYPENAME.clone(), self.id))?; Ok(()) } pub fn transaction( &mut self, message: &str, signer: &Device, operations: F, ) -> Result where G: crypto::signature::Signer, F: FnOnce(&mut Transaction, &R) -> Result<(), store::Error>, { let mut tx = Transaction::default(); operations(&mut tx, self.store.as_ref())?; let (doc, commit) = tx.commit(message, self.id, &mut self.store, signer)?; self.identity = doc; Ok(commit) } /// Update the identity by proposing a new revision. /// If the signer is the only delegate, the revision is accepted automatically. pub fn update( &mut self, title: impl ToString, description: impl ToString, doc: &Doc, signer: &Device, ) -> Result where G: crypto::signature::Signer, { let parent = self.current; let id = self.transaction("Propose revision", signer, |tx, repo| { tx.revision(title, description, doc, Some(parent), repo, signer) })?; Ok(id) } /// Accept an active revision. pub fn accept(&mut self, revision: &RevisionId, signer: &Device) -> Result where G: crypto::signature::Signer, { let id = *revision; let revision = self.revision(revision).ok_or(Error::NotFound(id))?; let signature = revision.sign(signer)?; self.transaction("Accept revision", signer, |tx, _| tx.accept(id, signature)) } /// Reject an active revision. pub fn reject(&mut self, revision: RevisionId, signer: &Device) -> Result where G: crypto::signature::Signer, { self.transaction("Reject revision", signer, |tx, _| tx.reject(revision)) } /// Redact a revision. pub fn redact(&mut self, revision: RevisionId, signer: &Device) -> Result where G: crypto::signature::Signer, { self.transaction("Redact revision", signer, |tx, _| tx.redact(revision)) } /// Edit an active revision's title or description. pub fn edit( &mut self, revision: RevisionId, title: String, description: String, signer: &Device, ) -> Result where G: crypto::signature::Signer, { self.transaction("Edit revision", signer, |tx, _| { tx.edit(revision, title, description) }) } } impl Deref for IdentityMut<'_, R> { type Target = Identity; fn deref(&self) -> &Self::Target { &self.identity } } mod lookup { use super::*; pub fn revision_mut<'a>( revisions: &'a mut BTreeMap>, revision: &RevisionId, ) -> Result, ApplyError> { match revisions.get_mut(revision) { Some(Some(revision)) => Ok(Some(revision)), // Redacted. Some(None) => Ok(None), // Missing. Causal error. None => Err(ApplyError::Missing(*revision)), } } pub fn revision<'a>( revisions: &'a BTreeMap>, revision: &RevisionId, ) -> Result, ApplyError> { match revisions.get(revision) { Some(Some(revision)) => Ok(Some(revision)), // Redacted. Some(None) => Ok(None), // Missing. Causal error. None => Err(ApplyError::Missing(*revision)), } } } #[cfg(test)] #[allow(clippy::unwrap_used)] mod test { use qcheck_macros::quickcheck; use crate::cob; use crate::crypto::PublicKey; use crate::identity::did::Did; use crate::identity::doc::PayloadId; use crate::identity::Visibility; use crate::rad; use crate::storage::git::Storage; use crate::storage::ReadStorage; use crate::test::fixtures; use crate::test::setup::{Network, NodeWithRepo}; use super::*; #[quickcheck] fn prop_json_eq_str(pk: PublicKey, proj: RepoId, did: Did) { let json = serde_json::to_string(&pk).unwrap(); assert_eq!(format!("\"{pk}\""), json); let json = serde_json::to_string(&proj).unwrap(); assert_eq!(format!("\"{}\"", proj.urn()), json); let json = serde_json::to_string(&did).unwrap(); assert_eq!(format!("\"{did}\""), json); } #[test] fn test_identity_updates() { let NodeWithRepo { node, repo } = NodeWithRepo::default(); let bob = Device::mock(); let signer = &node.signer; let mut identity = Identity::load_mut(&*repo).unwrap(); let mut doc = identity.doc().clone().edit(); let title = "Identity update"; let description = ""; let r0 = identity.current; // The initial state is accepted. assert!(identity.current().is_accepted()); // Using an identical document to the current one fails. identity .update(title, description, &doc.clone().verified().unwrap(), signer) .unwrap_err(); assert_eq!(identity.current, r0); // Change threshold to `2`, even though there's only one delegate. This should // fail as it makes the master branch immutable. doc.threshold = 2; assert!(doc.clone().verified().is_err()); // Let's add another delegate. doc.delegate(bob.public_key().into()); // The update should go through now. let r1 = identity .update(title, description, &doc.clone().verified().unwrap(), signer) .unwrap(); assert!(identity.revision(&r1).unwrap().is_accepted()); assert_eq!(identity.current, r1); // With two delegates now, we need two signatures for any update to go through. // So this next update shouldn't be accepted as canonical until the second delegate // signs it. doc.visibility = Visibility::private([]); let r2 = identity .update(title, description, &doc.clone().verified().unwrap(), signer) .unwrap(); // R1 is still the head. assert_eq!(identity.current, r1); assert_eq!(identity.revision(&r2).unwrap().state, State::Active); assert_eq!(repo.canonical_identity_head().unwrap(), r1); assert_eq!( repo.identity_doc().unwrap().visibility(), &Visibility::Public ); // Now let's add a signature on R2 from Bob. identity.accept(&r2, &bob).unwrap(); // R2 is now the head. assert_eq!(identity.current, r2); assert_eq!(identity.revision(&r2).unwrap().state, State::Accepted); assert_eq!(repo.canonical_identity_head().unwrap(), r2); assert_eq!( repo.canonical_identity_doc().unwrap().visibility(), &Visibility::private([]) ); } #[test] fn test_identity_update_rejected() { let NodeWithRepo { node, repo } = NodeWithRepo::default(); let bob = Device::mock(); let eve = Device::mock(); let signer = &node.signer; let mut identity = Identity::load_mut(&*repo).unwrap(); let mut doc = identity.doc().clone().edit(); let title = "Identity update"; let description = ""; // Let's add another delegate. doc.delegate(bob.public_key().into()); let r1 = identity .update(title, description, &doc.clone().verified().unwrap(), signer) .unwrap(); assert_eq!(identity.current, r1); doc.visibility = Visibility::private([]); let r2 = identity .update( "Make private", description, &doc.clone().verified().unwrap(), &node.signer, ) .unwrap(); // 1/2 rejected means that we can never reach the required 2/2 votes. identity.reject(r2, &bob).unwrap(); let r2 = identity.revision(&r2).unwrap(); assert_eq!(r2.state, State::Rejected); // Now let's add another delegate. doc.delegate(eve.public_key().into()); let r3 = identity .update( "Add Eve", description, &doc.clone().verified().unwrap(), &node.signer, ) .unwrap(); let _ = identity.accept(&r3, &bob).unwrap(); assert_eq!(identity.current, r3); doc.visibility = Visibility::Public; let r3 = identity .update( "Make public", description, &doc.verified().unwrap(), &node.signer, ) .unwrap(); // 1/3 rejected means that we can still reach the 2/3 required votes. identity.reject(r3, &bob).unwrap(); let r3 = identity.revision(&r3).unwrap().clone(); assert_eq!(r3.state, State::Active); // Still active. // 2/3 rejected means that we can no longer reach the 2/3 required votes. identity.reject(r3.id, &eve).unwrap(); let r3 = identity.revision(&r3.id).unwrap(); assert_eq!(r3.state, State::Rejected); } #[test] fn test_identity_updates_concurrent() { let network = Network::default(); let alice = &network.alice; let bob = &network.bob; let mut alice_identity = Identity::load_mut(&*alice.repo).unwrap(); let mut alice_doc = alice_identity.doc().clone().edit(); alice_doc.delegate(bob.signer.public_key().into()); let a1 = alice_identity .update( "Add Bob", "", &alice_doc.clone().verified().unwrap(), &alice.signer, ) .unwrap(); bob.repo.fetch(alice); let mut bob_identity = Identity::load_mut(&*bob.repo).unwrap(); let bob_doc = bob_identity.doc().clone(); assert!(bob_doc.is_delegate(&bob.signer.public_key().into())); // Alice changes the document without making Bob aware. alice_doc.visibility = Visibility::private([]); let a2 = alice_identity .update( "Change visibility", "", &alice_doc.clone().clone().verified().unwrap(), &alice.signer, ) .unwrap(); // Bob makes the same change without knowing Alice already did. let b1 = bob_identity .update( "Make private", "", &alice_doc.verified().unwrap(), &bob.signer, ) .unwrap(); // Bob gets Alice's data. bob.repo.fetch(alice); bob_identity.reload().unwrap(); assert_eq!(bob_identity.current, a1); // Alice gets Bob's data. // There's not enough votes for either of these proposals to pass. alice.repo.fetch(bob); alice_identity.reload().unwrap(); assert_eq!(alice_identity.current, a1); assert_eq!(bob_identity.revision(&a2).unwrap().state, State::Active); assert_eq!(bob_identity.revision(&b1).unwrap().state, State::Active); // Now Bob accepts Alice's proposal. This voids his own. bob_identity.accept(&a2, &bob.signer).unwrap(); assert_eq!(bob_identity.current, a2); assert_eq!(bob_identity.revision(&a1).unwrap().state, State::Accepted); assert_eq!(bob_identity.revision(&a2).unwrap().state, State::Accepted); assert_eq!(bob_identity.revision(&b1).unwrap().state, State::Stale); } #[test] fn test_identity_redact_revision() { let network = Network::default(); let alice = &network.alice; let bob = &network.bob; let eve = &network.eve; let mut alice_identity = Identity::load_mut(&*alice.repo).unwrap(); let mut alice_doc = alice_identity.doc().clone().edit(); alice_doc.delegate(bob.signer.public_key().into()); let a0 = alice_identity.root; let a1 = alice_identity .update( "Add Bob", "Eh.", &alice_doc.clone().clone().verified().unwrap(), &alice.signer, ) .unwrap(); alice_doc.visibility = Visibility::private([eve.signer.public_key().into()]); let a2 = alice_identity .update( "Change visibility", "Eh.", &alice_doc.verified().unwrap(), &alice.signer, ) .unwrap(); bob.repo.fetch(alice); let a3 = alice_identity.redact(a2, &alice.signer).unwrap(); assert!(alice_identity.revision(&a1).is_some()); assert_eq!(alice_identity.timeline, vec![a0, a1, a2, a3]); let mut bob_identity = Identity::load_mut(&*bob.repo).unwrap(); let b1 = bob_identity.accept(&a2, &bob.signer).unwrap(); assert_eq!(bob_identity.timeline, vec![a0, a1, a2, b1]); assert_eq!(bob_identity.revision(&a2).unwrap().state, State::Accepted); bob.repo.fetch(alice); bob_identity.reload().unwrap(); assert_eq!(bob_identity.timeline, vec![a0, a1, a2, a3, b1]); assert_eq!(bob_identity.revision(&a2), None); assert_eq!(bob_identity.current, a1); } #[test] fn test_identity_remove_delegate_concurrent() { let network = Network::default(); let alice = &network.alice; let bob = &network.bob; let eve = &network.eve; let mut alice_identity = Identity::load_mut(&*alice.repo).unwrap(); let mut alice_doc = alice_identity.doc().clone().edit(); alice_doc.delegate(bob.signer.public_key().into()); alice_doc.delegate(eve.signer.public_key().into()); let a0 = alice_identity.root; let a1 = alice_identity // Change description to change traversal order. .update( "Add Bob and Eve", "Eh#!", &alice_doc.clone().verified().unwrap(), &alice.signer, ) .unwrap(); alice_doc.rescind(&eve.signer.public_key().into()).unwrap(); let a2 = alice_identity .update( "Remove Eve", "", &alice_doc.verified().unwrap(), &alice.signer, ) .unwrap(); bob.repo.fetch(eve); bob.repo.fetch(alice); eve.repo.fetch(bob); let mut bob_identity = Identity::load_mut(&*bob.repo).unwrap(); let b1 = cob::git::stable::with_advanced_timestamp(|| { bob_identity.accept(&a2, &bob.signer).unwrap() }); assert_eq!(bob_identity.current, a2); let mut eve_identity = Identity::load_mut(&*eve.repo).unwrap(); let mut eve_doc = eve_identity.doc().clone().edit(); eve_doc.visibility = Visibility::private([eve.signer.public_key().into()]); let e1 = cob::git::stable::with_advanced_timestamp(|| { eve_identity .update( "Change visibility", "", &eve_doc.verified().unwrap(), &eve.signer, ) .unwrap() }); // Eve's revision is active. assert!(eve_identity.revision(&e1).unwrap().is_active()); // b1 (Accept "Remove Eve") 2/2 // | e1 (Change visibility) // | / // a2 (Propose "Remove Eve") 1/2 // | // a1 (Add Bob and Eve) // | // a0 eve.repo.fetch(bob); eve_identity.reload().unwrap(); // Now that Eve reloaded, since Bob's vote to remove Eve went through first (b1 < e1), // her revision is no longer valid. assert_eq!(eve_identity.timeline, vec![a0, a1, a2, b1, e1]); assert_eq!(eve_identity.revision(&e1), None); assert!(!eve_identity.is_delegate(&eve.signer.public_key().into())); } #[test] fn test_identity_reject_concurrent() { let network = Network::default(); let alice = &network.alice; let bob = &network.bob; let eve = &network.eve; let mut alice_identity = Identity::load_mut(&*alice.repo).unwrap(); let mut alice_doc = alice_identity.doc().clone().edit(); alice_doc.delegate(bob.signer.public_key().into()); alice_doc.delegate(eve.signer.public_key().into()); let a0 = alice_identity.root; let a1 = alice_identity .update( "Add Bob and Eve", "Eh!#", &alice_doc.clone().verified().unwrap(), &alice.signer, ) .unwrap(); alice_doc.visibility = Visibility::private([]); let a2 = alice_identity .update( "Change visibility", "", &alice_doc.verified().unwrap(), &alice.signer, ) .unwrap(); bob.repo.fetch(eve); bob.repo.fetch(alice); eve.repo.fetch(bob); // Bob accepts alice's revision. let mut bob_identity = Identity::load_mut(&*bob.repo).unwrap(); let b1 = bob_identity.accept(&a2, &bob.signer).unwrap(); // Eve rejects the revision, not knowing. let mut eve_identity = Identity::load_mut(&*eve.repo).unwrap(); let e1 = eve_identity.reject(a2, &eve.signer).unwrap(); assert!(eve_identity.revision(&a2).unwrap().is_active()); // Then she submits a new revision. let mut eve_doc = eve_identity.doc().clone().edit(); eve_doc.visibility = Visibility::private([eve.signer.public_key().into()]); let e2 = eve_identity .update( "Change visibility", "", &eve_doc.verified().unwrap(), &eve.signer, ) .unwrap(); assert!(eve_identity.revision(&e2).unwrap().is_active()); // e2 (Propose "Change visibility") 1/2 // | // e1 (Reject "Change visibility") 1/2 // b1 | (Accept "Change visibility") 2/2 // | / // a2 (Propose "Change visibility") 1/2 // | // a1 (Add Bob and Eve) // | // a0 // Though the rules are that you cannot reject an already accepted revision, // since this update was done concurrently there was no way of knowing. Therefore, // an error shouldn't be returned. We simply ignore the rejection. eve.repo.fetch(bob); eve_identity.reload().unwrap(); assert_eq!(eve_identity.timeline, vec![a0, a1, a2, b1, e1, e2]); // Her revision is there, although stale, since another revision was accepted since. // However, it wasn't pruned, even though rejecting an accepted revision is an error. let e2 = eve_identity.revision(&e2).unwrap(); assert_eq!(e2.state, State::Stale); assert!(eve_identity.revision(&a2).unwrap().is_accepted()); } #[test] fn test_identity_updates_concurrent_outdated() { let network = Network::default(); let alice = &network.alice; let bob = &network.bob; let eve = &network.eve; let mut alice_identity = Identity::load_mut(&*alice.repo).unwrap(); let mut alice_doc = alice_identity.doc().clone().edit(); alice.repo.fetch(bob); alice.repo.fetch(eve); alice_doc.delegate(bob.signer.public_key().into()); alice_doc.delegate(eve.signer.public_key().into()); let a0 = alice_identity.root; let a1 = alice_identity .update( "Add Bob and Eve", "", &alice_doc.verified().unwrap(), &alice.signer, ) .unwrap(); bob.repo.fetch(alice); eve.repo.fetch(alice); let mut bob_identity = Identity::load_mut(&*bob.repo).unwrap(); let mut bob_doc = bob_identity.doc().clone().edit(); assert!(bob_doc.is_delegate(&bob.signer.public_key().into())); // a2 e1 // | / // b1 // | // a1 // | // a0 // Bob and Alice change the document visibility. Eve is not aware. bob_doc.visibility = Visibility::private([]); let b1 = bob_identity .update( "Change visibility #1", "", &bob_doc.verified().unwrap(), &bob.signer, ) .unwrap(); alice.repo.fetch(bob); eve.repo.fetch(bob); // In the meantime, Eve does the same thing on her side. let mut eve_identity = Identity::load_mut(&*eve.repo).unwrap(); let mut eve_doc = eve_identity.doc().clone().edit(); eve_doc.visibility = Visibility::private([]); let e1 = eve_identity .update( "Change visibility #2", "Woops", &eve_doc.verified().unwrap(), &eve.signer, ) .unwrap(); assert_eq!(eve_identity.revisions().count(), 4); assert_eq!(eve_identity.revision(&e1).unwrap().state, State::Active); alice_identity.reload().unwrap(); let a2 = cob::git::stable::with_advanced_timestamp(|| { alice_identity.accept(&b1, &alice.signer).unwrap() }); eve.repo.fetch(alice); eve_identity.reload().unwrap(); assert_eq!(eve_identity.timeline, vec![a0, a1, b1, e1, a2]); assert_eq!(eve_identity.revision(&e1).unwrap().state, State::Stale); } #[test] fn test_valid_identity() { let tempdir = tempfile::tempdir().unwrap(); let mut rng = fastrand::Rng::new(); let alice = Device::mock_rng(&mut rng); let bob = Device::mock_rng(&mut rng); let eve = Device::mock_rng(&mut rng); let storage = Storage::open(tempdir.path().join("storage"), fixtures::user()).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(); let repo = storage.repository(id).unwrap(); let mut identity = Identity::load_mut(&repo).unwrap(); let doc = identity.doc().clone(); let prj = doc.project().unwrap(); let mut doc = doc.edit(); // Make a change to the description and sign it. let desc = prj.description().to_owned() + "!"; let prj = prj.update(None, desc, None).unwrap(); doc.payload.insert(PayloadId::project(), prj.clone().into()); identity .update( "Update description", "", &doc.clone().verified().unwrap(), &alice, ) .unwrap(); // Add Bob as a delegate, and sign it. doc.delegate(bob.public_key().into()); doc.threshold = 2; identity .update("Add bob", "", &doc.clone().verified().unwrap(), &alice) .unwrap(); // Add Eve as a delegate. doc.delegate(eve.public_key().into()); // Update with both Bob and Alice's signature. let revision = identity .update("Add eve", "", &doc.clone().verified().unwrap(), &alice) .unwrap(); identity.accept(&revision, &bob).unwrap(); // Update description again with signatures by Eve and Bob. let desc = prj.description().to_owned() + "?"; let prj = prj.update(None, desc, None).unwrap(); doc.payload.insert(PayloadId::project(), prj.into()); let revision = identity .update( "Update description again", "Bob's repository", &doc.verified().unwrap(), &bob, ) .unwrap(); identity.accept(&revision, &eve).unwrap(); let identity: Identity = Identity::load(&repo).unwrap(); let root = repo.identity_root().unwrap(); let doc = repo.identity_doc_at(revision).unwrap(); assert_eq!(identity.signatures().count(), 2); assert_eq!(identity.revisions().count(), 5); assert_eq!(identity.id(), id); assert_eq!(identity.root().id, root); assert_eq!(identity.current().blob, doc.blob); assert_eq!(identity.current().description.as_str(), "Bob's repository"); assert_eq!(identity.head(), revision); assert_eq!(identity.doc(), &*doc); assert_eq!( identity.doc().project().unwrap().description(), "Acme's repository!?" ); assert_eq!(doc.project().unwrap().description(), "Acme's repository!?"); } }