radicle-heartwood-lfs/radicle/src/identity/doc.rs

553 lines
16 KiB
Rust

mod id;
use std::collections::{BTreeMap, BTreeSet};
use std::fmt;
use std::marker::PhantomData;
use std::ops::{Deref, Not};
use std::path::Path;
use std::str::FromStr;
use nonempty::NonEmpty;
use once_cell::sync::Lazy;
use radicle_cob::type_name::{TypeName, TypeNameParse};
use radicle_git_ext::Oid;
use serde::{Deserialize, Serialize};
use thiserror::Error;
use crate::canonical::formatter::CanonicalFormatter;
use crate::cob::identity;
use crate::crypto;
use crate::crypto::{Signature, Unverified, Verified};
use crate::git;
use crate::identity::{project::Project, Did};
use crate::storage;
use crate::storage::{ReadRepository, RepositoryError};
pub use crypto::PublicKey;
pub use id::*;
/// Path to the identity document in the identity branch.
pub static PATH: Lazy<&Path> = Lazy::new(|| Path::new("radicle.json"));
/// Maximum length of a string in the identity document.
pub const MAX_STRING_LENGTH: usize = 255;
/// Maximum number of a delegates in the identity document.
pub const MAX_DELEGATES: usize = 255;
#[derive(Error, Debug)]
pub enum DocError {
#[error("json: {0}")]
Json(#[from] serde_json::Error),
#[error("invalid delegates: {0}")]
Delegates(&'static str),
#[error("invalid threshold `{0}`: {1}")]
Threshold(usize, &'static str),
#[error("git: {0}")]
GitExt(#[from] git::Error),
#[error("git: {0}")]
Git(#[from] git2::Error),
#[error("missing identity document")]
Missing,
}
impl DocError {
/// Whether this error is caused by the document not being found.
pub fn is_not_found(&self) -> bool {
match self {
Self::GitExt(git::Error::NotFound(_)) => true,
Self::GitExt(git::Error::Git(e)) if git::is_not_found_err(e) => true,
Self::Git(err) if git::is_not_found_err(err) => true,
_ => false,
}
}
}
/// Identifies an identity document payload type.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(transparent)]
pub struct PayloadId(TypeName);
impl fmt::Display for PayloadId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
self.0.fmt(f)
}
}
impl FromStr for PayloadId {
type Err = TypeNameParse;
fn from_str(s: &str) -> Result<Self, Self::Err> {
TypeName::from_str(s).map(Self)
}
}
impl PayloadId {
/// Project payload type.
pub fn project() -> Self {
Self(
// SAFETY: We know this is valid.
TypeName::from_str("xyz.radicle.project")
.expect("PayloadId::project: type name is valid"),
)
}
}
#[derive(Debug, Error)]
pub enum PayloadError {
#[error("json: {0}")]
Json(#[from] serde_json::Error),
#[error("payload '{0}' not found in identity document")]
NotFound(PayloadId),
}
/// Payload value.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(transparent)]
pub struct Payload {
value: serde_json::Value,
}
impl Payload {
/// Get a mutable reference to the JSON map, or `None` if the payload is not a map.
pub fn as_object_mut(
&mut self,
) -> Option<&mut serde_json::value::Map<String, serde_json::Value>> {
self.value.as_object_mut()
}
}
impl From<serde_json::Value> for Payload {
fn from(value: serde_json::Value) -> Self {
Self { value }
}
}
impl Deref for Payload {
type Target = serde_json::Value;
fn deref(&self) -> &Self::Target {
&self.value
}
}
/// A verified identity document at a specific commit.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DocAt {
/// The commit at which this document exists.
pub commit: Oid,
/// The document blob at this commit.
pub blob: Oid,
/// The parsed document.
pub doc: Doc<Verified>,
}
impl Deref for DocAt {
type Target = Doc<Verified>;
fn deref(&self) -> &Self::Target {
&self.doc
}
}
impl From<DocAt> for Doc<Verified> {
fn from(value: DocAt) -> Self {
value.doc
}
}
impl AsRef<Doc<Verified>> for DocAt {
fn as_ref(&self) -> &Doc<Verified> {
&self.doc
}
}
/// Repository visibility.
#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", tag = "type")]
pub enum Visibility {
/// Anyone and everyone.
#[default]
Public,
/// Delegates plus the allowed DIDs.
Private {
#[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
allow: BTreeSet<Did>,
},
}
#[derive(Error, Debug)]
#[error("'{0}' is not a valid visibility type")]
pub struct VisibilityParseError(String);
impl FromStr for Visibility {
type Err = VisibilityParseError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"public" => Ok(Visibility::Public),
"private" => Ok(Visibility::private([])),
_ => Err(VisibilityParseError(s.to_owned())),
}
}
}
impl Visibility {
/// Check whether the visibility is public.
pub fn is_public(&self) -> bool {
matches!(self, Self::Public)
}
/// Check whether the visibility is private.
pub fn is_private(&self) -> bool {
matches!(self, Self::Private { .. })
}
/// Private visibility with list of allowed DIDs beyond the repository delegates.
pub fn private(allow: impl IntoIterator<Item = Did>) -> Self {
Self::Private {
allow: BTreeSet::from_iter(allow),
}
}
}
/// An identity document.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Doc<V> {
/// The payload section.
pub payload: BTreeMap<PayloadId, Payload>,
/// The delegates section.
pub delegates: NonEmpty<Did>,
/// The signature threshold.
pub threshold: usize,
/// Repository visibility.
#[serde(default, skip_serializing_if = "Visibility::is_public")]
pub visibility: Visibility,
#[serde(skip)]
verified: PhantomData<V>,
}
impl<V> Doc<V> {
/// Check whether this document and the associated repository is visible to the given peer.
pub fn is_visible_to(&self, peer: &PublicKey) -> bool {
match &self.visibility {
Visibility::Public => true,
Visibility::Private { allow } => {
allow.contains(&Did::from(*peer)) || self.is_delegate(peer)
}
}
}
/// Validate signature using this document's delegates, against a given document blob.
pub fn verify_signature(
&self,
key: &PublicKey,
signature: &Signature,
blob: Oid,
) -> Result<(), PublicKey> {
if !self.is_delegate(key) {
return Err(*key);
}
if key.verify(blob.as_bytes(), signature).is_err() {
return Err(*key);
}
Ok(())
}
pub fn is_majority(&self, votes: usize) -> bool {
votes >= self.majority()
}
pub fn majority(&self) -> usize {
self.delegates.len() / 2 + 1
}
pub fn blob_at<R: ReadRepository>(commit: Oid, repo: &R) -> Result<git2::Blob, DocError> {
let path = Path::new("embeds").join(*PATH);
repo.blob_at(commit, path.as_path()).map_err(DocError::from)
}
pub fn is_delegate(&self, key: &crypto::PublicKey) -> bool {
self.delegates.contains(&key.into())
}
}
impl Doc<Verified> {
pub fn encode(&self) -> Result<(git::Oid, Vec<u8>), DocError> {
let mut buf = Vec::new();
let mut serializer =
serde_json::Serializer::with_formatter(&mut buf, CanonicalFormatter::new());
self.serialize(&mut serializer)?;
let oid = git2::Oid::hash_object(git2::ObjectType::Blob, &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, key: &crypto::PublicKey) -> bool {
let delegate = Did::from(key);
if self.delegates.iter().all(|id| id != &delegate) {
self.delegates.push(delegate);
return true;
}
false
}
pub fn rescind(&mut self, key: &crypto::PublicKey) -> Result<Option<Did>, DocError> {
let delegate = Did::from(key);
let (matches, delegates) = self.delegates.iter().partition(|d| **d == delegate);
match NonEmpty::from_vec(delegates) {
Some(delegates) => {
self.delegates = delegates;
if self.threshold > self.delegates.len() {
return Err(DocError::Threshold(
self.threshold,
"the thresholds exceeds the new delegate count after removal",
));
}
Ok(matches.is_empty().not().then_some(delegate))
}
None => Err(DocError::Delegates("cannot remove the last delegate")),
}
}
/// Get the project payload, if it exists and is valid, out of this document.
pub fn project(&self) -> Result<Project, PayloadError> {
let value = self
.payload
.get(&PayloadId::project())
.ok_or_else(|| PayloadError::NotFound(PayloadId::project()))?;
let proj: Project = serde_json::from_value((**value).clone())?;
Ok(proj)
}
pub fn sign<G: crypto::Signer>(
&self,
signer: &G,
) -> Result<(git::Oid, Vec<u8>, Signature), DocError> {
let (oid, bytes) = self.encode()?;
let sig = signer.sign(oid.as_bytes());
Ok((oid, bytes, sig))
}
pub fn signature_of<G: crypto::Signer>(&self, signer: &G) -> Result<Signature, DocError> {
let (_, _, sig) = self.sign(signer)?;
Ok(sig)
}
pub fn load_at<R: ReadRepository>(commit: Oid, repo: &R) -> Result<DocAt, DocError> {
let blob = Self::blob_at(commit, repo)?;
let doc = Doc::from_blob(&blob)?;
Ok(DocAt {
commit,
doc,
blob: blob.id().into(),
})
}
pub fn from_blob(blob: &git2::Blob) -> Result<Self, DocError> {
Doc::from_json(blob.content())?.verified()
}
pub fn init<G: crypto::Signer>(
&self,
repo: &storage::git::Repository,
signer: &G,
) -> Result<git::Oid, RepositoryError> {
let cob = identity::Identity::initialize(self, repo, signer)?;
let id_ref = git::refs::storage::id(signer.public_key());
let cob_ref = git::refs::storage::cob(
signer.public_key(),
&crate::cob::identity::TYPENAME,
&cob.id,
);
// Set `.../refs/rad/id` -> `.../refs/cobs/xyz.radicle.id/<id>`
repo.backend.reference_symbolic(
id_ref.as_str(),
cob_ref.as_str(),
false,
"Create `rad/id` reference to point to new identity COB",
)?;
Ok(*cob.id)
}
}
impl Doc<Unverified> {
pub fn initial(project: Project, delegate: Did, visibility: Visibility) -> Self {
Self::new(project, NonEmpty::new(delegate), 1, visibility)
}
pub fn new(
project: Project,
delegates: NonEmpty<Did>,
threshold: usize,
visibility: Visibility,
) -> Self {
let project =
serde_json::to_value(project).expect("Doc::initial: payload must be serializable");
Self {
payload: BTreeMap::from_iter([(PayloadId::project(), Payload::from(project))]),
delegates,
threshold,
visibility,
verified: PhantomData,
}
}
pub fn from_json(bytes: &[u8]) -> Result<Self, DocError> {
serde_json::from_slice(bytes).map_err(DocError::from)
}
pub fn verified(self) -> Result<Doc<Verified>, DocError> {
if self.delegates.len() > MAX_DELEGATES {
return Err(DocError::Delegates("number of delegates cannot exceed 255"));
}
if self.delegates.is_empty() {
return Err(DocError::Delegates("delegate list cannot be empty"));
}
if self.threshold > self.delegates.len() {
return Err(DocError::Threshold(
self.threshold,
"threshold cannot exceed number of delegates",
));
}
if self.threshold == 0 {
return Err(DocError::Threshold(
self.threshold,
"threshold cannot be zero",
));
}
Ok(Doc {
payload: self.payload,
delegates: self.delegates,
threshold: self.threshold,
visibility: self.visibility,
verified: PhantomData,
})
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod test {
use radicle_crypto::test::signer::MockSigner;
use radicle_crypto::Signer as _;
use crate::rad;
use crate::storage::git::transport;
use crate::storage::git::Storage;
use crate::storage::{ReadStorage as _, RemoteId, WriteStorage as _};
use crate::test::arbitrary;
use crate::test::fixtures;
use super::*;
use qcheck_macros::quickcheck;
#[test]
fn test_canonical_example() {
let tempdir = tempfile::tempdir().unwrap();
let storage = Storage::open(tempdir.path().join("storage"), fixtures::user()).unwrap();
transport::local::register(storage.clone());
let delegate = MockSigner::from_seed([0xff; 32]);
let (repo, _) = fixtures::repository(tempdir.path().join("working"));
let (id, _, _) = rad::init(
&repo,
"heartwood",
"Radicle Heartwood Protocol & Stack",
git::refname!("master"),
Visibility::default(),
&delegate,
&storage,
)
.unwrap();
assert_eq!(
delegate.public_key().to_human(),
String::from("z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi")
);
assert_eq!(
(*id).to_string(),
"d96f425412c9f8ad5d9a9a05c9831d0728e2338d"
);
assert_eq!(id.urn(), String::from("rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji"));
}
#[test]
fn test_not_found() {
let tempdir = tempfile::tempdir().unwrap();
let storage = Storage::open(tempdir.path().join("storage"), fixtures::user()).unwrap();
let remote = arbitrary::gen::<RemoteId>(1);
let proj = arbitrary::gen::<RepoId>(1);
let repo = storage.create(proj).unwrap();
let oid = git2::Oid::from_str("2d52a53ce5e4f141148a5f770cfd3ead2d6a45b8").unwrap();
let err = repo.identity_head_of(&remote).unwrap_err();
matches!(err, git::ext::Error::NotFound(_));
let err = Doc::<Verified>::load_at(oid.into(), &repo).unwrap_err();
assert!(err.is_not_found());
}
#[test]
fn test_canonical_doc() {
let tempdir = tempfile::tempdir().unwrap();
let storage = Storage::open(tempdir.path().join("storage"), fixtures::user()).unwrap();
transport::local::register(storage.clone());
let (working, _) = fixtures::repository(tempdir.path().join("working"));
let delegate = MockSigner::from_seed([0xff; 32]);
let (rid, doc, _) = rad::init(
&working,
"heartwood",
"Radicle Heartwood Protocol & Stack",
git::refname!("master"),
Visibility::default(),
&delegate,
&storage,
)
.unwrap();
let repo = storage.repository(rid).unwrap();
assert_eq!(doc, repo.identity_doc().unwrap().doc);
}
#[quickcheck]
fn prop_encode_decode(doc: Doc<Verified>) {
let (_, bytes) = doc.encode().unwrap();
assert_eq!(Doc::from_json(&bytes).unwrap().verified().unwrap(), doc);
}
#[test]
fn test_visibility_json() {
use std::str::FromStr;
assert_eq!(
serde_json::to_value(Visibility::Public).unwrap(),
serde_json::json!({ "type": "public" })
);
assert_eq!(
serde_json::to_value(Visibility::private([])).unwrap(),
serde_json::json!({ "type": "private" })
);
assert_eq!(
serde_json::to_value(Visibility::private([Did::from_str(
"did:key:z6MksFqXN3Yhqk8pTJdUGLwATkRfQvwZXPqR2qMEhbS9wzpT"
)
.unwrap()]))
.unwrap(),
serde_json::json!({ "type": "private", "allow": ["did:key:z6MksFqXN3Yhqk8pTJdUGLwATkRfQvwZXPqR2qMEhbS9wzpT"] })
);
}
}