node: Implement identity updates

Signed-off-by: Alexis Sellier <alexis@radicle.xyz>
This commit is contained in:
Alexis Sellier 2022-09-18 21:48:43 +02:00
parent fa4ac94829
commit 4ad8583805
No known key found for this signature in database
10 changed files with 340 additions and 98 deletions

View File

@ -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<PublicKey> 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

View File

@ -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<git2::Commit, git2::Error> {
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<git2::Tree<'r>, 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

View File

@ -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<Verified>;
fn deref(&self) -> &Self::Target {
&self.doc
}
}
#[cfg(test)]
mod test {
use super::*;

View File

@ -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<Delegate> for PublicKey {
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub struct Doc<V> {
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<Oid>,
}
#[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<V> {
#[serde(rename = "xyz.radicle.project")]
pub payload: Payload,
#[serde(flatten)]
pub extensions: BTreeMap<Namespace, serde_json::Value>,
pub delegates: NonEmpty<Delegate>,
pub threshold: usize,
@ -78,17 +91,100 @@ pub struct Doc<V> {
}
impl Doc<Verified> {
pub fn encode(&self) -> Result<(Id, Vec<u8>), Error> {
pub fn encode(&self) -> Result<(git::Oid, Vec<u8>), 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<G: crypto::Signer>(&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<git::Oid, Error> {
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<git::Oid, Error> {
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<V> Deref for Doc<V> {
type Target = Payload;
fn deref(&self) -> &Self::Target {
&self.payload
}
}
@ -118,11 +214,12 @@ impl Doc<Unverified> {
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<Unverified> {
name: String,
description: String,
default_branch: BranchName,
parent: Option<Oid>,
delegates: NonEmpty<Delegate>,
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<Unverified> {
"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<Unverified> {
}
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<V> Doc<V> {
}
}
#[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<V> {
/// The head of the identity branch. This points to a commit that
@ -284,6 +386,8 @@ pub struct Identity<V> {
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<Verified>,
/// Signatures over this identity.
@ -297,7 +401,7 @@ impl Identity<Untrusted> {
id: &Id,
remote: &RemoteId,
repo: &R,
) -> Result<Option<Self>, Error> {
) -> Result<Option<Identity<Trusted>>, IdentityError> {
if let Some(head) = Doc::<Untrusted>::head(remote, repo)? {
let mut history = repo.revwalk(head)?.collect::<Vec<_>>();
@ -306,10 +410,11 @@ impl Identity<Untrusted> {
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<Untrusted> {
// 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<Untrusted> {
// 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<Untrusted> {
.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<Untrusted> {
#[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<Verified>) {
let (_, bytes) = doc.encode().unwrap();

View File

@ -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<Verified>), 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();

View File

@ -252,6 +252,8 @@ pub trait ReadRepository<'r> {
fn references(&self, remote: &RemoteId) -> Result<Refs, Error>;
fn remote(&self, remote: &RemoteId) -> Result<Remote<Verified>, refs::Error>;
fn remotes(&'r self) -> Result<Self::Remotes, git2::Error>;
/// Return the project associated with this repository.
fn project(&self) -> Result<Project, Error>;
}
pub trait WriteRepository<'r>: ReadRepository<'r> {

View File

@ -363,6 +363,10 @@ impl<'r> ReadRepository<'r> for Repository {
Ok(Box::new(iter))
}
fn project(&self) -> Result<Project, Error> {
todo!()
}
}
impl<'r> WriteRepository<'r> for Repository {

View File

@ -179,7 +179,8 @@ impl Arbitrary for MockStorage {
impl Arbitrary for Project {
fn arbitrary(g: &mut quickcheck::Gen) -> Self {
let doc = Doc::<Verified>::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<Verified> {
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<Verified> {
.try_into()
.unwrap();
let threshold = delegates.len() / 2 + 1;
let doc: Doc<Unverified> = Doc::new(
name,
description,
default_branch,
parent,
delegates,
threshold,
);
let doc: Doc<Unverified> =
Doc::new(name, description, default_branch, delegates, threshold);
doc.verified().unwrap()
}

View File

@ -66,7 +66,7 @@ pub fn storage<P: AsRef<Path>>(path: P) -> Storage {
/// Create a new repository at the given path, and initialize it into a project.
pub fn project<'r, P: AsRef<Path>, S: WriteStorage<'r>, G: Signer>(
path: P,
storage: S,
storage: &'r S,
signer: G,
) -> Result<(Id, SignedRefs<Verified>, git2::Repository, git2::Oid), rad::InitError> {
let (repo, head) = repository(path);

View File

@ -125,6 +125,10 @@ impl ReadRepository<'_> for MockRepository {
fn references(&self, _remote: &RemoteId) -> Result<crate::storage::refs::Refs, Error> {
todo!()
}
fn project(&self) -> Result<Project, Error> {
todo!()
}
}
impl WriteRepository<'_> for MockRepository {