node: Start on identity history verification
Signed-off-by: Alexis Sellier <alexis@radicle.xyz>
This commit is contained in:
parent
73d62de0cc
commit
84119b0265
|
|
@ -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")]
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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::*;
|
||||
|
|
|
|||
|
|
@ -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(()) => {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue