Refactor identity verification
Introduces a new `DocAt` type. Signed-off-by: Alexis Sellier <alexis@radicle.xyz>
This commit is contained in:
parent
edafe055b4
commit
bdbf111163
|
|
@ -8,6 +8,7 @@ use anyhow::{anyhow, Context as _};
|
||||||
use chrono::prelude::*;
|
use chrono::prelude::*;
|
||||||
use json_color::{Color, Colorizer};
|
use json_color::{Color, Colorizer};
|
||||||
|
|
||||||
|
use radicle::crypto::Unverified;
|
||||||
use radicle::identity::Untrusted;
|
use radicle::identity::Untrusted;
|
||||||
use radicle::identity::{Doc, Id};
|
use radicle::identity::{Doc, Id};
|
||||||
use radicle::storage::{ReadRepository, ReadStorage, WriteStorage};
|
use radicle::storage::{ReadRepository, ReadStorage, WriteStorage};
|
||||||
|
|
@ -154,7 +155,7 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
|
||||||
for (counter, oid) in history.into_iter().rev().enumerate() {
|
for (counter, oid) in history.into_iter().rev().enumerate() {
|
||||||
let oid = oid?.into();
|
let oid = oid?.into();
|
||||||
let tip = repo.commit(oid)?;
|
let tip = repo.commit(oid)?;
|
||||||
let blob = Doc::blob_at(oid, &repo)?;
|
let blob = Doc::<Unverified>::blob_at(oid, &repo)?;
|
||||||
let content: serde_json::Value = serde_json::from_slice(blob.content())?;
|
let content: serde_json::Value = serde_json::from_slice(blob.content())?;
|
||||||
let timezone = if tip.time().sign() == '+' {
|
let timezone = if tip.time().sign() == '+' {
|
||||||
#[allow(deprecated)]
|
#[allow(deprecated)]
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,6 @@ use thiserror::Error;
|
||||||
use crate::crypto;
|
use crate::crypto;
|
||||||
use crate::crypto::{Signature, Verified};
|
use crate::crypto::{Signature, Verified};
|
||||||
use crate::git;
|
use crate::git;
|
||||||
use crate::storage::git::trailers;
|
|
||||||
use crate::storage::{ReadRepository, RemoteId};
|
use crate::storage::{ReadRepository, RemoteId};
|
||||||
|
|
||||||
pub use crypto::PublicKey;
|
pub use crypto::PublicKey;
|
||||||
|
|
@ -39,10 +38,6 @@ pub enum IdentityError {
|
||||||
MissingRootSignatures,
|
MissingRootSignatures,
|
||||||
#[error("commit signature for {0} is invalid: {1}")]
|
#[error("commit signature for {0} is invalid: {1}")]
|
||||||
InvalidSignature(PublicKey, crypto::Error),
|
InvalidSignature(PublicKey, crypto::Error),
|
||||||
#[error("commit message for {0} is invalid")]
|
|
||||||
InvalidCommitMessage(Oid),
|
|
||||||
#[error("commit trailers for {0} are invalid: {1}")]
|
|
||||||
InvalidCommitTrailers(Oid, trailers::Error),
|
|
||||||
#[error("threshold not reached: {0} signatures for a threshold of {1}")]
|
#[error("threshold not reached: {0} signatures for a threshold of {1}")]
|
||||||
ThresholdNotReached(usize, usize),
|
ThresholdNotReached(usize, usize),
|
||||||
#[error("identity document error: {0}")]
|
#[error("identity document error: {0}")]
|
||||||
|
|
@ -103,58 +98,28 @@ impl Identity<Untrusted> {
|
||||||
|
|
||||||
// Retrieve root document.
|
// Retrieve root document.
|
||||||
let root_oid = history.pop().ok_or(IdentityError::MissingRoot)??.into();
|
let root_oid = history.pop().ok_or(IdentityError::MissingRoot)??.into();
|
||||||
let root_blob = Doc::blob_at(root_oid, repo)?;
|
let root = Doc::<Verified>::load_at(root_oid, repo)?;
|
||||||
let root: git::Oid = root_blob.id().into();
|
|
||||||
let trusted = Doc::from_json(root_blob.content())?;
|
|
||||||
let revision = history.len() as u32;
|
let revision = history.len() as u32;
|
||||||
|
|
||||||
{
|
// Every identity founder must have signed the root document.
|
||||||
let root_commit = repo.commit(root_oid)?;
|
for founder in &root.doc.delegates {
|
||||||
let root_msg = root_commit
|
if !root.sigs.iter().any(|(k, _)| k == &**founder) {
|
||||||
.message_raw()
|
return Err(IdentityError::MissingRootSignatures);
|
||||||
.ok_or(IdentityError::InvalidCommitMessage(root_oid))?;
|
|
||||||
let root_sigs = trailers::parse_signatures(root_msg)
|
|
||||||
.map_err(|e| IdentityError::InvalidCommitTrailers(root_oid, e))?;
|
|
||||||
|
|
||||||
for (pk, sig) in &root_sigs {
|
|
||||||
if let Err(err) = pk.verify(root_blob.content(), sig) {
|
|
||||||
return Err(IdentityError::InvalidSignature(*pk, err));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Every identity founder must have signed the root document.
|
|
||||||
for founder in &trusted.delegates {
|
|
||||||
if !root_sigs.iter().any(|(k, _)| k == &**founder) {
|
|
||||||
return Err(IdentityError::MissingRootSignatures);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut trusted = trusted.verified()?;
|
let mut current = root.blob;
|
||||||
let mut current = root;
|
let mut trusted = root.doc;
|
||||||
let mut signatures = Vec::new();
|
let mut signatures = root.sigs;
|
||||||
|
|
||||||
// Traverse the history chronologically.
|
// Traverse the history chronologically.
|
||||||
for oid in history.into_iter().rev() {
|
for oid in history.into_iter().rev() {
|
||||||
let oid = oid?;
|
let oid = oid?;
|
||||||
let blob = Doc::blob_at(oid.into(), repo)?;
|
let untrusted = Doc::<Verified>::load_at(oid.into(), repo)?;
|
||||||
let untrusted = Doc::from_json(blob.content()).map_err(doc::DocError::from)?;
|
|
||||||
let untrusted = untrusted.verified()?;
|
|
||||||
let commit = repo.commit(oid.into())?;
|
|
||||||
let msg = commit
|
|
||||||
.message_raw()
|
|
||||||
.ok_or_else(|| IdentityError::InvalidCommitMessage(oid.into()))?;
|
|
||||||
|
|
||||||
// Keys that signed the *current* document version.
|
|
||||||
signatures = trailers::parse_signatures(msg)
|
|
||||||
.map_err(|e| IdentityError::InvalidCommitTrailers(oid.into(), e))?;
|
|
||||||
for (pk, sig) in &signatures {
|
|
||||||
if let Err(err) = pk.verify(blob.content(), sig) {
|
|
||||||
return Err(IdentityError::InvalidSignature(*pk, err));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check that enough delegates signed this next version.
|
// Check that enough delegates signed this next version.
|
||||||
let quorum = signatures
|
let quorum = untrusted
|
||||||
|
.sigs
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|(key, _)| trusted.delegates.iter().any(|d| &**d == key))
|
.filter(|(key, _)| trusted.delegates.iter().any(|d| &**d == key))
|
||||||
.count();
|
.count();
|
||||||
|
|
@ -165,12 +130,13 @@ impl Identity<Untrusted> {
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
trusted = untrusted;
|
current = untrusted.blob;
|
||||||
current = blob.id().into();
|
trusted = untrusted.doc;
|
||||||
|
signatures = untrusted.sigs;
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(Identity {
|
Ok(Identity {
|
||||||
root,
|
root: root.blob,
|
||||||
head,
|
head,
|
||||||
current,
|
current,
|
||||||
revision,
|
revision,
|
||||||
|
|
|
||||||
|
|
@ -32,10 +32,16 @@ pub const MAX_DELEGATES: usize = 255;
|
||||||
|
|
||||||
#[derive(Error, Debug)]
|
#[derive(Error, Debug)]
|
||||||
pub enum DocError {
|
pub enum DocError {
|
||||||
|
#[error("invalid commit: {0}")]
|
||||||
|
Commit(&'static str),
|
||||||
#[error("json: {0}")]
|
#[error("json: {0}")]
|
||||||
Json(#[from] serde_json::Error),
|
Json(#[from] serde_json::Error),
|
||||||
#[error("invalid delegates: {0}")]
|
#[error("invalid delegates: {0}")]
|
||||||
Delegates(&'static str),
|
Delegates(&'static str),
|
||||||
|
#[error("invalid signature for {0}: {1}")]
|
||||||
|
Signature(PublicKey, crypto::Error),
|
||||||
|
#[error("invalid commit trailers: {0}")]
|
||||||
|
Trailers(#[from] trailers::Error),
|
||||||
#[error("invalid version `{0}`")]
|
#[error("invalid version `{0}`")]
|
||||||
Version(u32),
|
Version(u32),
|
||||||
#[error("invalid threshold `{0}`: {1}")]
|
#[error("invalid threshold `{0}`: {1}")]
|
||||||
|
|
@ -106,11 +112,28 @@ impl Deref for Payload {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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>,
|
||||||
|
/// The validated commit signatures.
|
||||||
|
pub sigs: Vec<(PublicKey, Signature)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An identity document.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct Doc<V> {
|
pub struct Doc<V> {
|
||||||
|
/// The payload section.
|
||||||
pub payload: BTreeMap<PayloadId, Payload>,
|
pub payload: BTreeMap<PayloadId, Payload>,
|
||||||
|
/// The delegates section.
|
||||||
pub delegates: NonEmpty<Did>,
|
pub delegates: NonEmpty<Did>,
|
||||||
|
/// The signature threshold.
|
||||||
pub threshold: usize,
|
pub threshold: usize,
|
||||||
|
|
||||||
#[serde(skip)]
|
#[serde(skip)]
|
||||||
|
|
@ -122,6 +145,11 @@ impl<V> Doc<V> {
|
||||||
repo.reference_oid(remote, &git::refs::storage::IDENTITY_BRANCH)
|
repo.reference_oid(remote, &git::refs::storage::IDENTITY_BRANCH)
|
||||||
.map_err(DocError::from)
|
.map_err(DocError::from)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn blob_at<R: ReadRepository>(commit: Oid, repo: &R) -> Result<git2::Blob, DocError> {
|
||||||
|
repo.blob_at(commit, Path::new(&*PATH))
|
||||||
|
.map_err(DocError::from)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Doc<Verified> {
|
impl Doc<Verified> {
|
||||||
|
|
@ -164,6 +192,28 @@ impl Doc<Verified> {
|
||||||
Ok((oid, sig))
|
Ok((oid, sig))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn load_at<R: ReadRepository>(oid: Oid, repo: &R) -> Result<DocAt, DocError> {
|
||||||
|
let blob = Self::blob_at(oid, repo)?;
|
||||||
|
let doc = Doc::from_json(blob.content())?.verified()?;
|
||||||
|
let commit = repo.commit(oid)?;
|
||||||
|
let msg = commit
|
||||||
|
.message_raw()
|
||||||
|
.ok_or(DocError::Commit("commit message is not UTF-8"))?;
|
||||||
|
let sigs = trailers::parse_signatures(msg)?;
|
||||||
|
|
||||||
|
for (pk, sig) in &sigs {
|
||||||
|
if let Err(err) = pk.verify(blob.content(), sig) {
|
||||||
|
return Err(DocError::Signature(*pk, err));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(DocAt {
|
||||||
|
commit: oid,
|
||||||
|
doc,
|
||||||
|
blob: blob.id().into(),
|
||||||
|
sigs,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
pub fn init(
|
pub fn init(
|
||||||
doc: &[u8],
|
doc: &[u8],
|
||||||
remote: &RemoteId,
|
remote: &RemoteId,
|
||||||
|
|
@ -266,11 +316,6 @@ impl Doc<Unverified> {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn blob_at<R: ReadRepository>(commit: Oid, repo: &R) -> Result<git2::Blob, DocError> {
|
|
||||||
repo.blob_at(commit, Path::new(&*PATH))
|
|
||||||
.map_err(DocError::from)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn load_at<R: ReadRepository>(commit: Oid, repo: &R) -> Result<(Self, Oid), DocError> {
|
pub fn load_at<R: ReadRepository>(commit: Oid, repo: &R) -> Result<(Self, Oid), DocError> {
|
||||||
let blob = Self::blob_at(commit, repo)?;
|
let blob = Self::blob_at(commit, repo)?;
|
||||||
let doc = Doc::from_json(blob.content())?;
|
let doc = Doc::from_json(blob.content())?;
|
||||||
|
|
@ -345,7 +390,7 @@ mod test {
|
||||||
let err = Doc::<Unverified>::head(&remote, &repo).unwrap_err();
|
let err = Doc::<Unverified>::head(&remote, &repo).unwrap_err();
|
||||||
assert!(err.is_not_found());
|
assert!(err.is_not_found());
|
||||||
|
|
||||||
let err = Doc::load_at(oid.into(), &repo).unwrap_err();
|
let err = Doc::<Unverified>::load_at(oid.into(), &repo).unwrap_err();
|
||||||
assert!(err.is_not_found());
|
assert!(err.is_not_found());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -347,7 +347,7 @@ impl Repository {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Doc::load_at(longest.into(), self)
|
Doc::<Unverified>::load_at(longest.into(), self)
|
||||||
.map(|(doc, _)| (longest.into(), doc))
|
.map(|(doc, _)| (longest.into(), doc))
|
||||||
.map_err(ProjectError::from)
|
.map_err(ProjectError::from)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue