Radish alpha
h
Radicle Heartwood Protocol & Stack
Radicle
Git (anonymous pull)
Log in to clone via SSH
node: Start on identity history verification
Alexis Sellier committed 3 years ago
commit 84119b02656657fd7db9d8d042d00c02d7ca8215
parent 73d62de0cc7af246093147bb00e4f4548b0c1808
9 files changed +299 -31
modified node/src/crypto.rs
@@ -6,7 +6,6 @@ use serde::{Deserialize, Serialize};
use thiserror::Error;

pub use ed25519::Error;
-
pub use ed25519::Signature;

/// Verified (used as type witness).
#[derive(Debug, Copy, Clone, PartialEq, Eq)]
@@ -48,6 +47,64 @@ where
    }
}

+
/// Cryptographic signature.
+
#[derive(PartialEq, Eq, Debug, Copy, Clone)]
+
pub struct Signature(pub ed25519::Signature);
+

+
impl fmt::Display for Signature {
+
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+
        let base = multibase::Base::Base58Btc;
+
        write!(f, "{}", multibase::encode(base, &self.to_bytes()))
+
    }
+
}
+

+
#[derive(Error, Debug)]
+
pub enum SignatureError {
+
    #[error("invalid multibase string: {0}")]
+
    Multibase(#[from] multibase::Error),
+
    #[error("invalid signature: {0}")]
+
    Invalid(#[from] ed25519::Error),
+
}
+

+
impl From<ed25519::Signature> for Signature {
+
    fn from(other: ed25519::Signature) -> Self {
+
        Self(other)
+
    }
+
}
+

+
impl FromStr for Signature {
+
    type Err = SignatureError;
+

+
    fn from_str(s: &str) -> Result<Self, Self::Err> {
+
        let (_, bytes) = multibase::decode(s)?;
+
        let sig = ed25519::Signature::try_from(bytes.as_slice())?;
+

+
        Ok(Self(sig))
+
    }
+
}
+

+
impl Deref for Signature {
+
    type Target = ed25519::Signature;
+

+
    fn deref(&self) -> &Self::Target {
+
        &self.0
+
    }
+
}
+

+
impl From<[u8; 64]> for Signature {
+
    fn from(bytes: [u8; 64]) -> Self {
+
        Self(ed25519::Signature::from(bytes))
+
    }
+
}
+

+
impl TryFrom<&[u8]> for Signature {
+
    type Error = ed25519::Error;
+

+
    fn try_from(bytes: &[u8]) -> Result<Self, Self::Error> {
+
        ed25519::Signature::try_from(bytes).map(Self)
+
    }
+
}
+

/// The public/verification key.
#[derive(Serialize, Deserialize, Eq, Debug, Copy, Clone)]
#[serde(into = "String", try_from = "String")]
modified node/src/git.rs
@@ -8,6 +8,7 @@ use crate::identity::PublicKey;
use crate::storage::refs::Refs;
use crate::storage::RemoteId;

+
pub use ext::Error;
pub use ext::Oid;
pub use git_ref_format as fmt;
pub use git_ref_format::{refname, RefStr, RefString};
modified node/src/identity/doc.rs
@@ -1,3 +1,4 @@
+
use std::collections::HashMap;
use std::io;
use std::marker::PhantomData;
use std::path::Path;
@@ -8,14 +9,26 @@ use radicle_git_ext::Oid;
use serde::{Deserialize, Serialize};
use thiserror::Error;

-
use crate::crypto::{self, Unverified, Verified};
+
use crate::crypto;
+
use crate::crypto::{Signature, Unverified, Verified};
+
use crate::git;
use crate::hash;
use crate::identity::{Did, Id};
-
use crate::storage::BranchName;
+
use crate::storage::git::trailers;
+
use crate::storage::{BranchName, ReadRepository, RemoteId};

pub use crypto::PublicKey;

+
/// 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;
+

+
pub static REFERENCE_NAME: Lazy<git::RefString> = Lazy::new(|| git::refname!("heads/radicle/id"));
pub static PATH: Lazy<&Path> = Lazy::new(|| Path::new("radicle.json"));
+

pub const MAX_STRING_LENGTH: usize = 255;
pub const MAX_DELEGATES: usize = 255;

@@ -25,6 +38,12 @@ pub enum Error {
    Json(#[from] serde_json::Error),
    #[error("i/o: {0}")]
    Io(#[from] io::Error),
+
    #[error("verification: {0}")]
+
    Verification(#[from] VerificationError),
+
    #[error("git: {0}")]
+
    Git(#[from] git::Error),
+
    #[error("git: {0}")]
+
    RawGit(#[from] git2::Error),
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
@@ -33,13 +52,25 @@ pub struct Delegate {
    pub id: Did,
}

+
impl Delegate {
+
    fn matches(&self, key: &PublicKey) -> bool {
+
        &self.id.0 == key
+
    }
+
}
+

+
impl From<Delegate> for PublicKey {
+
    fn from(delegate: Delegate) -> Self {
+
        delegate.id.0
+
    }
+
}
+

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub struct Doc<V> {
    pub name: String,
-
    pub description: String,
-
    pub default_branch: String,
-
    pub version: u32,
+
    pub description: String,    // TODO: Make optional.
+
    pub default_branch: String, // TODO: Make optional.
+
    pub version: u32,           // TODO: Remove this.
    pub parent: Option<Oid>,
    pub delegates: NonEmpty<Delegate>,
    pub threshold: usize,
@@ -201,6 +232,132 @@ impl Doc<Unverified> {
            verified: PhantomData,
        })
    }
+

+
    pub fn blob_at<'r, R: ReadRepository<'r>>(
+
        commit: Oid,
+
        repo: &R,
+
    ) -> Result<Option<git2::Blob>, git::Error> {
+
        match repo.blob_at(commit, Path::new(&*PATH)) {
+
            Err(git::ext::Error::NotFound(_)) => Ok(None),
+
            Err(e) => Err(e),
+
            Ok(blob) => Ok(Some(blob)),
+
        }
+
    }
+

+
    pub fn load_at<'r, R: ReadRepository<'r>>(
+
        commit: Oid,
+
        repo: &R,
+
    ) -> Result<Option<(Self, Oid)>, git::Error> {
+
        if let Some(blob) = Self::blob_at(commit, repo)? {
+
            let doc = Doc::from_json(blob.content()).unwrap();
+
            return Ok(Some((doc, blob.id().into())));
+
        }
+
        Ok(None)
+
    }
+

+
    pub fn load<'r, R: ReadRepository<'r>>(
+
        remote: &RemoteId,
+
        repo: &R,
+
    ) -> Result<Option<(Self, Oid)>, git::Error> {
+
        if let Some(oid) = Self::head(remote, repo)? {
+
            Self::load_at(oid, repo)
+
        } else {
+
            Ok(None)
+
        }
+
    }
+
}
+

+
impl<V> Doc<V> {
+
    pub fn head<'r, R: ReadRepository<'r>>(
+
        remote: &RemoteId,
+
        repo: &R,
+
    ) -> Result<Option<Oid>, git::Error> {
+
        if let Some(oid) = repo.reference_oid(remote, &REFERENCE_NAME)? {
+
            Ok(Some(oid))
+
        } else {
+
            Ok(None)
+
        }
+
    }
+
}
+

+
#[derive(Clone, Debug, PartialEq, Eq)]
+
pub struct Identity<V> {
+
    /// The head of the identity branch. This points to a commit that
+
    /// contains the current document blob.
+
    pub head: Oid,
+
    /// The object id of the initial document blob.
+
    /// This is the canonical identifier for this identity.
+
    pub root: Oid,
+
    /// The object id of the current document blob.
+
    pub current: Oid,
+
    /// The current document.
+
    pub doc: Doc<Verified>,
+
    /// Signatures over this identity.
+
    pub signatures: HashMap<PublicKey, Signature>,
+

+
    verified: PhantomData<V>,
+
}
+

+
impl Identity<Untrusted> {
+
    pub fn load<'r, R: ReadRepository<'r>>(
+
        _id: &Id,
+
        remote: &RemoteId,
+
        repo: &R,
+
    ) -> Result<Option<Self>, Error> {
+
        if let Some(head) = Doc::<Untrusted>::head(remote, repo)? {
+
            let mut history = repo.revwalk(head)?.collect::<Vec<_>>();
+

+
            // Retrieve root document.
+
            let root = history.pop().unwrap()?.into();
+
            let root = Doc::blob_at(root, repo)?.unwrap();
+
            let trusted = Doc::from_json(root.content()).unwrap();
+
            // TODO: Check the root document matches ID.
+

+
            let mut trusted = trusted.verified()?;
+
            let mut current = root.id().into();
+
            let mut signatures = Vec::new();
+

+
            for oid in history.into_iter().rev() {
+
                let oid = oid?;
+
                let blob = Doc::blob_at(head, repo)?.unwrap();
+
                let untrusted = Doc::from_json(blob.content()).unwrap();
+
                let untrusted = untrusted.verified()?;
+
                let commit = repo.commit(oid.into())?.unwrap();
+
                let msg = commit.message_raw().unwrap();
+

+
                // 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!();
+
                    }
+
                }
+

+
                // Check that enough delegates from the previous version signed this version.
+
                let quorum = signatures
+
                    .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!();
+
                }
+

+
                trusted = untrusted;
+
                current = blob.id().into();
+
            }
+

+
            return Ok(Some(Self {
+
                root: root.id().into(),
+
                head,
+
                current,
+
                doc: trusted,
+
                signatures: signatures.into_iter().collect(),
+
                verified: PhantomData,
+
            }));
+
        }
+
        Ok(None)
+
    }
}

#[cfg(test)]
modified node/src/rad.rs
@@ -6,7 +6,6 @@ use thiserror::Error;
use crate::crypto::{Signer, Verified};
use crate::git;
use crate::identity::Id;
-
use crate::storage::git::RADICLE_ID_REF;
use crate::storage::refs::SignedRefs;
use crate::storage::{BranchName, ReadRepository as _, RemoteId, WriteRepository as _};
use crate::{identity, storage};
@@ -84,7 +83,7 @@ pub fn init<'r, G: Signer, S: storage::WriteStorage<'r>>(
            .signature()
            .or_else(|_| git2::Signature::now("radicle", pk.to_string().as_str()))?;

-
        let id_ref = format!("refs/remotes/{pk}/{}", &*RADICLE_ID_REF);
+
        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(
modified node/src/storage.rs
@@ -7,7 +7,6 @@ use std::ops::Deref;
use std::path::Path;
use std::{fmt, io};

-
use radicle_git_ext as git_ext;
use thiserror::Error;

pub use radicle_git_ext::Oid;
@@ -15,6 +14,7 @@ pub use radicle_git_ext::Oid;
use crate::collections::HashMap;
use crate::crypto;
use crate::crypto::{PublicKey, Signer, Unverified, Verified};
+
use crate::git::ext as git_ext;
use crate::git::Url;
use crate::git::{RefError, RefStr, RefString};
use crate::identity;
@@ -242,6 +242,8 @@ pub trait ReadRepository<'r> {
        remote: &RemoteId,
        reference: &RefStr,
    ) -> Result<Option<git2::Reference>, git2::Error>;
+
    fn commit(&self, oid: Oid) -> Result<Option<git2::Commit>, git2::Error>;
+
    fn revwalk(&self, head: Oid) -> Result<git2::Revwalk, git2::Error>;
    fn reference_oid(
        &self,
        remote: &RemoteId,
modified node/src/storage/git.rs
@@ -20,7 +20,6 @@ use crate::storage::{

use super::{RefUpdate, RemoteId};

-
pub static RADICLE_ID_REF: Lazy<git::RefString> = Lazy::new(|| git::refname!("heads/radicle/id"));
pub static REMOTES_GLOB: Lazy<refspec::PatternString> =
    Lazy::new(|| refspec::pattern!("refs/remotes/*"));
pub static SIGNATURES_GLOB: Lazy<refspec::PatternString> =
@@ -257,21 +256,11 @@ impl Repository {
        &self,
        remote: &RemoteId,
    ) -> Result<Option<identity::Doc<Verified>>, refs::Error> {
-
        let oid = if let Some(oid) = self.reference_oid(remote, &RADICLE_ID_REF)? {
-
            oid
+
        if let Some((doc, _)) = identity::Doc::load(remote, self)? {
+
            Ok(Some(doc.verified().unwrap()))
        } else {
-
            return Ok(None);
-
        };
-

-
        let doc = match self.blob_at(oid, Path::new(&*identity::doc::PATH)) {
-
            Err(git::ext::Error::NotFound(_)) => return Ok(None),
-
            Err(e) => return Err(e.into()),
-
            Ok(doc) => doc,
-
        };
-
        let doc = identity::Doc::from_json(doc.content()).unwrap();
-
        let doc = doc.verified().unwrap();
-

-
        Ok(Some(doc))
+
            Ok(None)
+
        }
    }
}

@@ -287,7 +276,7 @@ impl<'r> ReadRepository<'r> for Repository {
        self.backend.path()
    }

-
    fn blob_at<'a>(&'a self, oid: Oid, path: &'a Path) -> Result<git2::Blob<'a>, git::ext::Error> {
+
    fn blob_at<'a>(&'a self, oid: Oid, path: &'a Path) -> Result<git2::Blob<'a>, git::Error> {
        git::ext::Blob::At {
            object: oid.into(),
            path,
@@ -311,6 +300,23 @@ impl<'r> ReadRepository<'r> for Repository {
        })
    }

+
    fn commit(&self, oid: Oid) -> Result<Option<git2::Commit>, git2::Error> {
+
        self.backend.find_commit(oid.into()).map(Some).or_else(|e| {
+
            if git::ext::is_not_found_err(&e) {
+
                Ok(None)
+
            } else {
+
                Err(e)
+
            }
+
        })
+
    }
+

+
    fn revwalk(&self, head: Oid) -> Result<git2::Revwalk, git2::Error> {
+
        let mut revwalk = self.backend.revwalk()?;
+
        revwalk.push(head.into())?;
+

+
        Ok(revwalk)
+
    }
+

    fn reference_oid(
        &self,
        remote: &RemoteId,
@@ -452,6 +458,46 @@ impl From<git2::Repository> for Repository {
    }
}

+
pub mod trailers {
+
    use std::str::FromStr;
+

+
    use super::*;
+
    use crate::crypto::{PublicKey, PublicKeyError};
+
    use crate::crypto::{Signature, SignatureError};
+

+
    pub const SIGNATURE_TRAILER: &str = "Rad-Signature";
+

+
    #[derive(Error, Debug)]
+
    pub enum Error {
+
        #[error("invalid format for signature trailer")]
+
        SignatureTrailerFormat,
+
        #[error("invalid public key in signature trailer")]
+
        PublicKey(#[from] PublicKeyError),
+
        #[error("invalid signature in trailer")]
+
        Signature(#[from] SignatureError),
+
    }
+

+
    pub fn parse_signatures(msg: &str) -> Result<Vec<(PublicKey, Signature)>, Error> {
+
        let trailers =
+
            git2::message_trailers_strs(msg).map_err(|_| Error::SignatureTrailerFormat)?;
+
        let mut signatures = Vec::with_capacity(trailers.len());
+

+
        for (key, val) in trailers.iter() {
+
            if key == SIGNATURE_TRAILER {
+
                if let Some((pk, sig)) = val.split_once(' ') {
+
                    let pk = PublicKey::from_str(pk)?;
+
                    let sig = Signature::from_str(sig)?;
+

+
                    signatures.push((pk, sig));
+
                } else {
+
                    return Err(Error::SignatureTrailerFormat);
+
                }
+
            }
+
        }
+
        Ok(signatures)
+
    }
+
}
+

#[cfg(test)]
mod tests {
    use super::*;
modified node/src/storage/refs.rs
@@ -230,7 +230,7 @@ impl SignedRefs<Verified> {
    {
        let refs = repo.blob_at(oid, Path::new(REFS_BLOB_PATH))?;
        let signature = repo.blob_at(oid, Path::new(SIGNATURE_BLOB_PATH))?;
-
        let signature = crypto::Signature::try_from(signature.content())?;
+
        let signature: crypto::Signature = signature.content().try_into()?;

        match remote.verify(&signature, refs.content()) {
            Ok(()) => {
modified node/src/test/crypto.rs
@@ -1,6 +1,4 @@
-
use ed25519_consensus as ed25519;
-

-
use crate::crypto::{PublicKey, SecretKey, Signer};
+
use crate::crypto::{PublicKey, SecretKey, Signature, Signer};

#[derive(Debug, Clone)]
pub struct MockSigner {
@@ -57,7 +55,7 @@ impl Signer for MockSigner {
        &self.pk
    }

-
    fn sign(&self, msg: &[u8]) -> ed25519::Signature {
-
        self.sk.sign(msg)
+
    fn sign(&self, msg: &[u8]) -> Signature {
+
        self.sk.sign(msg).into()
    }
}
modified node/src/test/storage.rs
@@ -90,6 +90,14 @@ impl ReadRepository<'_> for MockRepository {
        todo!()
    }

+
    fn commit(&self, _oid: git::Oid) -> Result<Option<git2::Commit>, git2::Error> {
+
        todo!()
+
    }
+

+
    fn revwalk(&self, _head: git::Oid) -> Result<git2::Revwalk, git2::Error> {
+
        todo!()
+
    }
+

    fn blob_at<'a>(
        &'a self,
        _oid: radicle_git_ext::Oid,