| |
}
|
| |
}
|
| |
|
| - |
/// An identity document.
|
| - |
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
| + |
/// `RawDoc` is similar to the [`Doc`] type, however, it can be edited and may
|
| + |
/// not be valid.
|
| + |
///
|
| + |
/// It is expected that any changes to a [`Doc`] are made via [`RawDoc`], and
|
| + |
/// then verified by using [`RawDoc::verified`].
|
| + |
///
|
| + |
/// Note that `RawDoc` only implements [`Deserialize`]. This prevents us from
|
| + |
/// serializing an unverified document, while also making sure that any document
|
| + |
/// that is deserialized is verified.
|
| + |
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
|
| |
#[serde(rename_all = "camelCase")]
|
| - |
pub struct Doc<V> {
|
| + |
pub struct RawDoc {
|
| |
/// The payload section.
|
| |
pub payload: BTreeMap<PayloadId, Payload>,
|
| |
/// The delegates section.
|
| - |
pub delegates: NonEmpty<Did>,
|
| + |
pub delegates: Vec<Did>,
|
| |
/// The signature threshold.
|
| |
pub threshold: usize,
|
| |
/// Repository visibility.
|
| - |
#[serde(default, skip_serializing_if = "Visibility::is_public")]
|
| + |
#[serde(default)]
|
| |
pub visibility: Visibility,
|
| + |
}
|
| + |
|
| + |
impl TryFrom<RawDoc> for Doc {
|
| + |
type Error = DocError;
|
| + |
|
| + |
fn try_from(doc: RawDoc) -> Result<Self, Self::Error> {
|
| + |
doc.verified()
|
| + |
}
|
| + |
}
|
| + |
|
| + |
impl RawDoc {
|
| + |
/// Construct a new [`RawDoc`] with an initial [`RawDoc::payload`]
|
| + |
/// containing the provided [`Project`], and the given `delegates`,
|
| + |
/// `threshold`, and `visibility`.
|
| + |
pub fn new(
|
| + |
project: Project,
|
| + |
delegates: Vec<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,
|
| + |
}
|
| + |
}
|
| + |
|
| + |
/// 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)
|
| + |
}
|
| + |
|
| + |
/// Check if the given `did` is in the set of [`RawDoc::delegates`].
|
| + |
pub fn is_delegate(&self, did: &Did) -> bool {
|
| + |
self.delegates.contains(did)
|
| + |
}
|
| + |
|
| + |
/// Add a new delegate to the document.
|
| + |
///
|
| + |
/// Note that if this `Did` is a duplicate, then the resulting set will only
|
| + |
/// show it once.
|
| + |
pub fn delegate(&mut self, did: Did) {
|
| + |
self.delegates.push(did)
|
| + |
}
|
| + |
|
| + |
/// Remove the `did` from the set of delegates. Returns `true` if it was
|
| + |
/// removed.
|
| + |
pub fn rescind(&mut self, did: &Did) -> Result<bool, DocError> {
|
| + |
let (matches, delegates) = self.delegates.iter().partition(|d| *d == did);
|
| + |
self.delegates = delegates;
|
| + |
Ok(matches.is_empty().not())
|
| + |
}
|
| + |
|
| + |
/// Construct the `RawDoc` from the set of `bytes` that are expected to be
|
| + |
/// in JSON format.
|
| + |
pub fn from_json(bytes: &[u8]) -> Result<Self, DocError> {
|
| + |
serde_json::from_slice(bytes).map_err(DocError::from)
|
| + |
}
|
| + |
|
| + |
/// Verify the `RawDoc`'s values, converting it into a valid [`Doc`].
|
| + |
///
|
| + |
/// The verifications are as follows:
|
| + |
///
|
| + |
/// - [`RawDoc::delegates`]: any duplicates are removed, and for the
|
| + |
/// remaining set ensure that it is non-empty and does not exceed a
|
| + |
/// length of [`MAX_DELEGATES`].
|
| + |
/// - [`RawDoc::threshold`]: ensure that it is in the range `[1, delegates.len()]`.
|
| + |
pub fn verified(self) -> Result<Doc, DocError> {
|
| + |
let RawDoc {
|
| + |
payload,
|
| + |
delegates,
|
| + |
threshold,
|
| + |
visibility,
|
| + |
} = self;
|
| + |
let delegates = Delegates::new(delegates)?;
|
| + |
let threshold = Threshold::new(threshold, &delegates)?;
|
| + |
Ok(Doc {
|
| + |
payload,
|
| + |
delegates,
|
| + |
threshold,
|
| + |
visibility,
|
| + |
})
|
| + |
}
|
| + |
}
|
| |
|
| - |
#[serde(skip)]
|
| - |
verified: PhantomData<V>,
|
| + |
/// A valid set of delegates for the identity [`Doc`].
|
| + |
///
|
| + |
/// It can only be constructed via [`Delegates::new`].
|
| + |
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
| + |
#[serde(try_from = "Vec<Did>")]
|
| + |
pub struct Delegates(NonEmpty<Did>);
|
| + |
|
| + |
impl AsRef<NonEmpty<Did>> for Delegates {
|
| + |
fn as_ref(&self) -> &NonEmpty<Did> {
|
| + |
&self.0
|
| + |
}
|
| + |
}
|
| + |
|
| + |
impl TryFrom<Vec<Did>> for Delegates {
|
| + |
type Error = DelegatesError;
|
| + |
|
| + |
fn try_from(dids: Vec<Did>) -> Result<Self, Self::Error> {
|
| + |
Delegates::new(dids)
|
| + |
}
|
| + |
}
|
| + |
|
| + |
impl IntoIterator for Delegates {
|
| + |
type Item = <NonEmpty<Did> as IntoIterator>::Item;
|
| + |
type IntoIter = <NonEmpty<Did> as IntoIterator>::IntoIter;
|
| + |
|
| + |
fn into_iter(self) -> Self::IntoIter {
|
| + |
self.0.into_iter()
|
| + |
}
|
| + |
}
|
| + |
|
| + |
impl Delegates {
|
| + |
/// Construct the set of `Delegates` by removing any duplicate [`Did`]s,
|
| + |
/// ensure that the set is non-empty, and check the length does not exceed
|
| + |
/// the [`MAX_DELEGATES`].
|
| + |
pub fn new(delegates: impl IntoIterator<Item = Did>) -> Result<Self, DelegatesError> {
|
| + |
let delegates = delegates
|
| + |
.into_iter()
|
| + |
.try_fold(Vec::<Did>::new(), |mut dids, did| {
|
| + |
if !dids.contains(&did) {
|
| + |
if dids.len() >= MAX_DELEGATES {
|
| + |
return Err(DelegatesError("number of delegates cannot exceed 255"));
|
| + |
}
|
| + |
dids.push(did);
|
| + |
}
|
| + |
Ok(dids)
|
| + |
})?;
|
| + |
NonEmpty::from_vec(delegates)
|
| + |
.map(Self)
|
| + |
.ok_or(DelegatesError("delegate list cannot be empty"))
|
| + |
}
|
| + |
|
| + |
/// Get the first delegate in the set.
|
| + |
pub fn first(&self) -> &Did {
|
| + |
self.0.first()
|
| + |
}
|
| + |
|
| + |
/// Obtain an iterator over the [`Did`]s.
|
| + |
pub fn iter(&self) -> impl Iterator<Item = &Did> {
|
| + |
self.0.iter()
|
| + |
}
|
| + |
|
| + |
/// Check if the set contains the given `did`.
|
| + |
pub fn contains(&self, did: &Did) -> bool {
|
| + |
self.0.contains(did)
|
| + |
}
|
| + |
|
| + |
/// Get the number of delegates in the set.
|
| + |
pub fn len(&self) -> usize {
|
| + |
self.0.len()
|
| + |
}
|
| + |
|
| + |
/// Check if the set is empty. Note that this always returns `false`.
|
| + |
pub fn is_empty(&self) -> bool {
|
| + |
false
|
| + |
}
|
| |
}
|
| |
|
| - |
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 {
|
| + |
impl<'a> From<&'a Delegates> for &'a NonEmpty<Did> {
|
| + |
fn from(ds: &'a Delegates) -> Self {
|
| + |
&ds.0
|
| + |
}
|
| + |
}
|
| + |
|
| + |
impl From<Delegates> for NonEmpty<Did> {
|
| + |
fn from(ds: Delegates) -> Self {
|
| + |
ds.0
|
| + |
}
|
| + |
}
|
| + |
|
| + |
impl From<Delegates> for Vec<Did> {
|
| + |
fn from(Delegates(ds): Delegates) -> Self {
|
| + |
ds.into()
|
| + |
}
|
| + |
}
|
| + |
|
| + |
/// A valid threshold for the identity [`Doc`].
|
| + |
///
|
| + |
/// It can only be constructed via [`Threshold::new`] or [`Threshold::MIN`].
|
| + |
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
|
| + |
#[serde(transparent)]
|
| + |
pub struct Threshold(NonZeroUsize);
|
| + |
|
| + |
impl From<Threshold> for usize {
|
| + |
fn from(Threshold(t): Threshold) -> Self {
|
| + |
t.get()
|
| + |
}
|
| + |
}
|
| + |
|
| + |
impl AsRef<NonZeroUsize> for Threshold {
|
| + |
fn as_ref(&self) -> &NonZeroUsize {
|
| + |
&self.0
|
| + |
}
|
| + |
}
|
| + |
|
| + |
impl fmt::Display for Threshold {
|
| + |
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
| + |
write!(f, "{}", self.0)
|
| + |
}
|
| + |
}
|
| + |
|
| + |
impl Threshold {
|
| + |
/// A threshold of `1`.
|
| + |
pub const MIN: Threshold = Threshold(NonZeroUsize::MIN);
|
| + |
|
| + |
/// Construct the `Threshold` by checking that `t` is not greater than
|
| + |
/// [`MAX_DELEGATES`], that it does not exceed the number of delegates, and
|
| + |
/// is non-zero.
|
| + |
pub fn new(t: usize, delegates: &Delegates) -> Result<Self, ThresholdError> {
|
| + |
if t > MAX_DELEGATES {
|
| + |
Err(ThresholdError(t, "threshold cannot exceed 255"))
|
| + |
} else if t > delegates.len() {
|
| + |
Err(ThresholdError(
|
| + |
t,
|
| + |
"threshold cannot exceed number of delegates",
|
| + |
))
|
| + |
} else {
|
| + |
NonZeroUsize::new(t)
|
| + |
.map(Self)
|
| + |
.ok_or(ThresholdError(t, "threshold cannot be zero"))
|
| + |
}
|
| + |
}
|
| + |
}
|
| + |
|
| + |
/// `Doc` is a valid identity document.
|
| + |
///
|
| + |
/// To ensure that only valid documents are used, this type is restricted to be
|
| + |
/// read-only. For mutating the document use [`Doc::edit`].
|
| + |
///
|
| + |
/// A valid `Doc` can be constructed in four ways:
|
| + |
///
|
| + |
/// 1. [`Doc::initial`]: a safe way to construct the initial document for an identity.
|
| + |
/// 2. [`RawDoc::verified`]: validates a [`RawDoc`]'s fields and converts it
|
| + |
/// into a `Doc`
|
| + |
/// 3. [`Deserialize`]: will deserialize a `Doc` by first deserializing a
|
| + |
/// [`RawDoc`] and use [`RawDoc::verified`] to construct the `Doc`.
|
| + |
/// 4. [`Doc::from_blob`]: construct a `Doc` from a Git blob by deserializing
|
| + |
/// its contents.
|
| + |
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
| + |
#[serde(rename_all = "camelCase")]
|
| + |
#[serde(try_from = "RawDoc")]
|
| + |
pub struct Doc {
|
| + |
payload: BTreeMap<PayloadId, Payload>,
|
| + |
delegates: Delegates,
|
| + |
threshold: Threshold,
|
| + |
#[serde(default, skip_serializing_if = "Visibility::is_public")]
|
| + |
visibility: Visibility,
|
| + |
}
|
| + |
|
| + |
impl Doc {
|
| + |
/// Construct the initial [`Doc`] for an identity.
|
| + |
///
|
| + |
/// It will begin with the provided `project` in the [`Doc::payload`], the
|
| + |
/// `delegate` as the sole delegate, a threshold of 1, and the given
|
| + |
/// `visibility`.
|
| + |
pub fn initial(project: Project, delegate: Did, 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: Delegates(NonEmpty::new(delegate)),
|
| + |
threshold: Threshold(NonZeroUsize::MIN),
|
| + |
visibility,
|
| + |
}
|
| + |
}
|
| + |
|
| + |
/// Construct a [`Doc`] contained in the provided Git blob.
|
| + |
pub fn from_blob(blob: &git2::Blob) -> Result<Self, DocError> {
|
| + |
RawDoc::from_json(blob.content())?.verified()
|
| + |
}
|
| + |
|
| + |
/// Convert the [`Doc`] into a [`RawDoc`] for changing the field values and
|
| + |
/// re-verifying.
|
| + |
pub fn edit(self) -> RawDoc {
|
| + |
let Doc {
|
| + |
payload,
|
| + |
delegates,
|
| + |
threshold,
|
| + |
visibility,
|
| + |
} = self;
|
| + |
RawDoc {
|
| + |
payload,
|
| + |
delegates: delegates.into(),
|
| + |
threshold: threshold.into(),
|
| + |
visibility,
|
| + |
}
|
| + |
}
|
| + |
|
| + |
/// Using the current state of the `Doc`, perform any edits on the `RawDoc`
|
| + |
/// form and verify the changes.
|
| + |
pub fn with_edits<F>(self, f: F) -> Result<Self, DocError>
|
| + |
where
|
| + |
F: FnOnce(&mut RawDoc),
|
| + |
{
|
| + |
let mut raw = self.edit();
|
| + |
f(&mut raw);
|
| + |
raw.verified()
|
| + |
}
|
| + |
|
| + |
/// Return the associated payloads for this [`Doc`].
|
| + |
pub fn payload(&self) -> &BTreeMap<PayloadId, Payload> {
|
| + |
&self.payload
|
| + |
}
|
| + |
|
| + |
/// 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)
|
| + |
}
|
| + |
|
| + |
/// Return the associated [`Visibility`] of this document.
|
| + |
pub fn visibility(&self) -> &Visibility {
|
| + |
&self.visibility
|
| + |
}
|
| + |
|
| + |
/// Check whether the visibility of the document is public.
|
| + |
pub fn is_public(&self) -> bool {
|
| + |
self.visibility.is_public()
|
| + |
}
|
| + |
|
| + |
/// Check whether the visibility of the document is private.
|
| + |
pub fn is_private(&self) -> bool {
|
| + |
self.visibility.is_private()
|
| + |
}
|
| + |
|
| + |
/// Return the associated threshold of this document.
|
| + |
pub fn threshold(&self) -> usize {
|
| + |
self.threshold.into()
|
| + |
}
|
| + |
|
| + |
/// Return the associated threshold of this document in its non-zero format.
|
| + |
pub fn threshold_nonzero(&self) -> &NonZeroUsize {
|
| + |
&self.threshold.0
|
| + |
}
|
| + |
|
| + |
/// Return the associated delegates of this document.
|
| + |
pub fn delegates(&self) -> &Delegates {
|
| + |
&self.delegates
|
| + |
}
|
| + |
|
| + |
/// Check if the `did` is part of the [`Doc::delegates`] set.
|
| + |
pub fn is_delegate(&self, did: &Did) -> bool {
|
| + |
self.delegates.contains(did)
|
| + |
}
|
| + |
|
| + |
/// Check whether this document and the associated repository is visible to
|
| + |
/// the given peer.
|
| + |
pub fn is_visible_to(&self, did: &Did) -> bool {
|
| |
match &self.visibility {
|
| |
Visibility::Public => true,
|
| - |
Visibility::Private { allow } => {
|
| - |
allow.contains(&Did::from(*peer)) || self.is_delegate(peer)
|
| - |
}
|
| + |
Visibility::Private { allow } => allow.contains(did) || self.is_delegate(did),
|
| |
}
|
| |
}
|
| |
|
| - |
/// Validate signature using this document's delegates, against a given document blob.
|
| + |
/// 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) {
|
| + |
if !self.is_delegate(&key.into()) {
|
| |
return Err(*key);
|
| |
}
|
| |
if key.verify(blob.as_bytes(), signature).is_err() {
|
| |
}
|
| |
}
|
| |
|
| - |
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::assert_matches;
|
| |
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::arbitrary::gen;
|
| |
use crate::test::fixtures;
|
| |
|
| |
use super::*;
|
| |
use qcheck_macros::quickcheck;
|
| |
|
| |
#[test]
|
| + |
fn test_duplicate_dids() {
|
| + |
let delegate = MockSigner::from_seed([0xff; 32]);
|
| + |
let did = Did::from(delegate.public_key());
|
| + |
let mut doc = RawDoc::new(gen::<Project>(1), vec![did], 1, Visibility::Public);
|
| + |
doc.delegate(did);
|
| + |
let doc = doc.verified().unwrap();
|
| + |
assert!(doc.delegates().len() == 1, "Duplicate DID was not removed");
|
| + |
assert!(doc.delegates().first() == &did)
|
| + |
}
|
| + |
|
| + |
#[test]
|
| + |
fn test_max_delegates() {
|
| + |
// Generate more than the max delegates
|
| + |
let delegates = (0..MAX_DELEGATES + 1).map(gen).collect::<Vec<Did>>();
|
| + |
|
| + |
// A document with max delegates will be fine
|
| + |
let doc = RawDoc::new(
|
| + |
gen::<Project>(1),
|
| + |
delegates[0..MAX_DELEGATES].into(),
|
| + |
1,
|
| + |
Visibility::Public,
|
| + |
);
|
| + |
assert_matches!(doc.verified(), Ok(_));
|
| + |
|
| + |
// A document that exceeds max delegates should fail
|
| + |
let doc = RawDoc::new(gen::<Project>(1), delegates, 1, Visibility::Public);
|
| + |
assert_matches!(doc.verified(), Err(DocError::Delegates(DelegatesError(_))));
|
| + |
}
|
| + |
|
| + |
#[test]
|
| |
fn test_canonical_example() {
|
| |
let tempdir = tempfile::tempdir().unwrap();
|
| |
let storage = Storage::open(tempdir.path().join("storage"), fixtures::user()).unwrap();
|