diff --git a/node/src/crypto.rs b/node/src/crypto.rs index ec42b3eb..6210edad 100644 --- a/node/src/crypto.rs +++ b/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 for Signature { + fn from(other: ed25519::Signature) -> Self { + Self(other) + } +} + +impl FromStr for Signature { + type Err = SignatureError; + + fn from_str(s: &str) -> Result { + 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 { + ed25519::Signature::try_from(bytes).map(Self) + } +} + /// The public/verification key. #[derive(Serialize, Deserialize, Eq, Debug, Copy, Clone)] #[serde(into = "String", try_from = "String")] diff --git a/node/src/git.rs b/node/src/git.rs index 65298f4f..6abed468 100644 --- a/node/src/git.rs +++ b/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}; diff --git a/node/src/identity/doc.rs b/node/src/identity/doc.rs index 2330d8e4..1fc7cb91 100644 --- a/node/src/identity/doc.rs +++ b/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 = 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 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 { 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, pub delegates: NonEmpty, pub threshold: usize, @@ -201,6 +232,132 @@ impl Doc { verified: PhantomData, }) } + + pub fn blob_at<'r, R: ReadRepository<'r>>( + commit: Oid, + repo: &R, + ) -> Result, 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, 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, git::Error> { + if let Some(oid) = Self::head(remote, repo)? { + Self::load_at(oid, repo) + } else { + Ok(None) + } + } +} + +impl Doc { + pub fn head<'r, R: ReadRepository<'r>>( + remote: &RemoteId, + repo: &R, + ) -> Result, 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 { + /// 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, + /// Signatures over this identity. + pub signatures: HashMap, + + verified: PhantomData, +} + +impl Identity { + pub fn load<'r, R: ReadRepository<'r>>( + _id: &Id, + remote: &RemoteId, + repo: &R, + ) -> Result, Error> { + if let Some(head) = Doc::::head(remote, repo)? { + let mut history = repo.revwalk(head)?.collect::>(); + + // 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)] diff --git a/node/src/rad.rs b/node/src/rad.rs index 34e28a12..d034e5e3 100644 --- a/node/src/rad.rs +++ b/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( diff --git a/node/src/storage.rs b/node/src/storage.rs index a6ad471e..cdc7d302 100644 --- a/node/src/storage.rs +++ b/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, git2::Error>; + fn commit(&self, oid: Oid) -> Result, git2::Error>; + fn revwalk(&self, head: Oid) -> Result; fn reference_oid( &self, remote: &RemoteId, diff --git a/node/src/storage/git.rs b/node/src/storage/git.rs index a928e61e..e5a02cb7 100644 --- a/node/src/storage/git.rs +++ b/node/src/storage/git.rs @@ -20,7 +20,6 @@ use crate::storage::{ use super::{RefUpdate, RemoteId}; -pub static RADICLE_ID_REF: Lazy = Lazy::new(|| git::refname!("heads/radicle/id")); pub static REMOTES_GLOB: Lazy = Lazy::new(|| refspec::pattern!("refs/remotes/*")); pub static SIGNATURES_GLOB: Lazy = @@ -257,21 +256,11 @@ impl Repository { &self, remote: &RemoteId, ) -> Result>, 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, git::ext::Error> { + fn blob_at<'a>(&'a self, oid: Oid, path: &'a Path) -> Result, 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, 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 { + let mut revwalk = self.backend.revwalk()?; + revwalk.push(head.into())?; + + Ok(revwalk) + } + fn reference_oid( &self, remote: &RemoteId, @@ -452,6 +458,46 @@ impl From 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, 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::*; diff --git a/node/src/storage/refs.rs b/node/src/storage/refs.rs index b56a61af..4e09feee 100644 --- a/node/src/storage/refs.rs +++ b/node/src/storage/refs.rs @@ -230,7 +230,7 @@ impl SignedRefs { { 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(()) => { diff --git a/node/src/test/crypto.rs b/node/src/test/crypto.rs index 7b8e735f..65008c39 100644 --- a/node/src/test/crypto.rs +++ b/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() } } diff --git a/node/src/test/storage.rs b/node/src/test/storage.rs index 42244fad..d97ed283 100644 --- a/node/src/test/storage.rs +++ b/node/src/test/storage.rs @@ -90,6 +90,14 @@ impl ReadRepository<'_> for MockRepository { todo!() } + fn commit(&self, _oid: git::Oid) -> Result, git2::Error> { + todo!() + } + + fn revwalk(&self, _head: git::Oid) -> Result { + todo!() + } + fn blob_at<'a>( &'a self, _oid: radicle_git_ext::Oid,