use vti_common::error::AppError;
use crate::wire::AuthorityPresentation;
use crate::{Room, Visibility};
pub const MAX_CHAIN_DEPTH: usize = 8;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Action {
Read,
Write,
Curate,
Admin,
}
impl Action {
pub fn as_str(&self) -> &'static str {
match self {
Action::Read => "read",
Action::Write => "write",
Action::Curate => "curate",
Action::Admin => "admin",
}
}
}
pub const ACTION_SUCCEED: &str = "succeed";
#[derive(Debug, Clone)]
pub struct VerifiedChain {
pub subject: String,
pub actions: Vec<String>,
}
#[async_trait::async_trait]
pub trait ChainVerifier: Send + Sync {
async fn verify(
&self,
room: &Room,
presentation: &AuthorityPresentation,
action: Action,
presenter: &str,
) -> Result<VerifiedChain, AppError>;
}
#[derive(Debug, Clone, Copy, Default)]
pub struct RefusesEverything;
#[async_trait::async_trait]
impl ChainVerifier for RefusesEverything {
async fn verify(
&self,
room: &Room,
_presentation: &AuthorityPresentation,
_action: Action,
_presenter: &str,
) -> Result<VerifiedChain, AppError> {
Err(AppError::Forbidden(format!(
"room `{}` has no chain verifier configured on this host, and a chain nobody \
verified authorizes nothing",
room.room_id
)))
}
}
#[derive(Debug)]
pub struct AuthorizedAction {
action: Action,
room_id: String,
verified: VerifiedChain,
}
impl AuthorizedAction {
pub fn action(&self) -> Action {
self.action
}
pub fn room_id(&self) -> &str {
&self.room_id
}
pub fn subject(&self) -> &str {
&self.verified.subject
}
pub fn conferred(&self) -> &[String] {
&self.verified.actions
}
}
#[derive(Debug)]
pub struct AuthorizedCreate {
room_id: String,
owner_did: String,
}
impl AuthorizedCreate {
pub fn room_id(&self) -> &str {
&self.room_id
}
pub fn owner_did(&self) -> &str {
&self.owner_did
}
}
pub fn authorize_create(
room_id: &str,
owner_did: &str,
presenter: &str,
) -> Result<AuthorizedCreate, AppError> {
if room_id.trim().is_empty() {
return Err(AppError::Forbidden(
"a room must be registered under an identifier its owner minted".into(),
));
}
if owner_did.trim().is_empty() {
return Err(AppError::Forbidden(
"a room must name an owner: it is the accountable party, and a room without one \
is a room nobody can be addressed about"
.into(),
));
}
if presenter.trim().is_empty() {
return Err(AppError::Forbidden(
"no authenticated presenter; a registration nobody signed records an owner \
nobody proved"
.into(),
));
}
if did_of(presenter) != did_of(owner_did) {
return Err(AppError::Forbidden(format!(
"this registration was signed by `{}`, which is not the owner it names; a room \
is registered by the party accountable for it",
did_of(presenter)
)));
}
Ok(AuthorizedCreate {
room_id: room_id.trim().to_string(),
owner_did: did_of(owner_did).to_string(),
})
}
fn did_of(did: &str) -> &str {
let did = did.trim();
did.split('#').next().unwrap_or(did)
}
pub async fn authorize(
room: &Room,
presentation: &AuthorityPresentation,
action: Action,
presenter: &str,
now: u64,
verifier: &dyn ChainVerifier,
) -> Result<AuthorizedAction, AppError> {
if presentation.authority.is_empty() {
return Err(AppError::Forbidden(
"no authority chain presented; a room operation is authorized by the chain, \
never by this service's own records"
.into(),
));
}
if presentation.authority.len() > MAX_CHAIN_DEPTH {
return Err(AppError::Forbidden(format!(
"authority chain is {} deep, exceeding the maximum of {MAX_CHAIN_DEPTH}",
presentation.authority.len()
)));
}
if presentation.membership.trim().is_empty() {
return Err(AppError::Forbidden(
"no membership credential presented".into(),
));
}
if matches!(room.visibility, Visibility::Private) && presentation.subject_binding.is_none() {
return Err(AppError::Forbidden(
"a private room requires a subject binding proving the membership credential and \
the authority chain describe the same subject; without it two parties can pool \
credentials"
.into(),
));
}
if presenter.trim().is_empty() {
return Err(AppError::Forbidden(
"no authenticated presenter; a presentation not bound to the party that signed \
the request is replayable by anyone who observes it"
.into(),
));
}
if let Some(primary) = &room.mirror_of
&& action != Action::Read
{
return Err(AppError::Forbidden(format!(
"this host holds a read mirror of room `{}`; {} goes to the write-primary at {primary}",
room.room_id,
action.as_str()
)));
}
let lifecycle = room.lifecycle(now);
if !matches!(action, Action::Admin) && !lifecycle.accepts_writes() && action != Action::Read {
return Err(AppError::Forbidden(format!(
"room `{}` is {} and accepts no writes until its epoch is renewed; reads and \
export still work, and a single `rooms/epoch/mint` restores it",
room.room_id,
lifecycle.as_str()
)));
}
let verified = verifier
.verify(room, presentation, action, presenter)
.await?;
if !verified.actions.iter().any(|a| a == action.as_str()) {
return Err(AppError::Forbidden(format!(
"the chain confers {:?}, which does not include `{}`",
verified.actions,
action.as_str()
)));
}
if verified.subject != presenter {
return Err(AppError::Forbidden(format!(
"the chain grants to `{}`, not to the party that signed this request",
verified.subject
)));
}
Ok(AuthorizedAction {
action,
room_id: room.room_id.clone(),
verified,
})
}
#[cfg(test)]
mod tests {
use super::*;
fn room(visibility: Visibility) -> Room {
Room {
room_id: "did:key:zRoom".into(),
owner_did: "did:key:zOwner".into(),
visibility,
retention_policy: crate::RetentionPolicy::Chained,
epoch: 1,
next_version: 1,
retention_days: 90,
epoch_expires_at: None,
created_at: 0,
updated_at: 0,
mirror_of: None,
}
}
const PRESENTER: &str = "did:key:zAgent";
const NOW: u64 = 1_800_000_000;
fn presentation(depth: usize, binding: bool) -> AuthorityPresentation {
AuthorityPresentation {
membership: "vmc".into(),
authority: (0..depth).map(|i| format!("vac-{i}")).collect(),
subject_binding: binding.then(|| "binding".to_string()),
}
}
#[test]
fn an_owner_registers_their_own_room() {
let ok = authorize_create("did:key:zRoom", "did:key:zOwner", "did:key:zOwner")
.expect("an owner may register the room they are accountable for");
assert_eq!(ok.room_id(), "did:key:zRoom");
assert_eq!(ok.owner_did(), "did:key:zOwner");
}
#[test]
fn nobody_registers_a_room_owned_by_somebody_else() {
let err = authorize_create("did:key:zRoom", "did:key:zOwner", "did:key:zMallory")
.expect_err("a signer who is not the named owner must be refused");
assert!(
matches!(err, AppError::Forbidden(_)),
"must be Forbidden, was {err:?}"
);
}
#[test]
fn an_unsigned_registration_is_refused() {
assert!(authorize_create("did:key:zRoom", "did:key:zOwner", "").is_err());
assert!(authorize_create("did:key:zRoom", "", "").is_err());
assert!(authorize_create("", "did:key:zOwner", "did:key:zOwner").is_err());
}
#[test]
fn a_verification_method_fragment_is_not_a_different_party() {
assert!(
authorize_create(
"did:key:zRoom",
"did:key:zOwner",
"did:key:zOwner#z6MkKeyOne"
)
.is_ok()
);
assert!(
authorize_create(
"did:key:zRoom",
"did:key:zOwner#z6MkKeyOne",
"did:key:zOwner"
)
.is_ok()
);
assert!(
authorize_create(
"did:key:zRoom",
"did:key:zOwner",
"did:key:zOther#z6MkKeyOne"
)
.is_err(),
"a fragment must not make two different DIDs equal"
);
}
#[tokio::test]
async fn a_mirror_serves_reads_and_refuses_every_write() {
let mirror = Room {
mirror_of: Some("https://primary.example.org".into()),
..room(Visibility::Open)
};
authorize(
&mirror,
&presentation(1, false),
Action::Read,
PRESENTER,
NOW,
&Vouches::for_all(),
)
.await
.expect("a read is what a mirror is for");
for action in [Action::Write, Action::Curate, Action::Admin] {
let err = authorize(
&mirror,
&presentation(1, false),
action,
PRESENTER,
NOW,
&Vouches::for_all(),
)
.await
.expect_err("a mirror performs no writes");
assert!(
matches!(&err, AppError::Forbidden(m) if m.contains("primary.example.org")),
"{action:?}: {err:?}"
);
}
}
#[tokio::test]
async fn a_mirror_refuses_an_epoch_mint_even_though_a_lapsed_primary_would_not() {
let lapsed_mirror = Room {
mirror_of: Some("https://primary.example.org".into()),
epoch_expires_at: Some(NOW - 1),
..room(Visibility::Open)
};
let err = authorize(
&lapsed_mirror,
&presentation(1, false),
Action::Admin,
PRESENTER,
NOW,
&Vouches::for_all(),
)
.await
.expect_err("renewal happens at the primary");
assert!(
matches!(&err, AppError::Forbidden(m) if m.contains("read mirror")),
"the mirror gate must answer first: {err:?}"
);
}
struct Vouches(Vec<String>);
impl Vouches {
fn for_all() -> Self {
Self(
["read", "write", "curate", "admin"]
.iter()
.map(|s| s.to_string())
.collect(),
)
}
fn read_only() -> Self {
Self(vec!["read".into()])
}
}
#[async_trait::async_trait]
impl ChainVerifier for Vouches {
async fn verify(
&self,
_room: &Room,
_presentation: &AuthorityPresentation,
_action: Action,
presenter: &str,
) -> Result<VerifiedChain, AppError> {
Ok(VerifiedChain {
subject: presenter.to_string(),
actions: self.0.clone(),
})
}
}
struct VouchesForSomeoneElse;
#[async_trait::async_trait]
impl ChainVerifier for VouchesForSomeoneElse {
async fn verify(
&self,
_room: &Room,
_presentation: &AuthorityPresentation,
_action: Action,
_presenter: &str,
) -> Result<VerifiedChain, AppError> {
Ok(VerifiedChain {
subject: "did:key:zSomeoneElse".into(),
actions: vec!["read".into()],
})
}
}
#[tokio::test]
async fn a_verified_presentation_authorizes_what_the_chain_confers() {
let ok = authorize(
&room(Visibility::Open),
&presentation(2, false),
Action::Write,
PRESENTER,
NOW,
&Vouches::for_all(),
)
.await
.expect("should authorize");
assert_eq!(ok.action(), Action::Write);
assert_eq!(ok.room_id(), "did:key:zRoom");
assert_eq!(
ok.subject(),
PRESENTER,
"the subject is the verifier's finding, never the caller's claim"
);
}
#[tokio::test]
async fn a_read_only_chain_cannot_write() {
let err = authorize(
&room(Visibility::Open),
&presentation(2, false),
Action::Write,
PRESENTER,
NOW,
&Vouches::read_only(),
)
.await
.unwrap_err();
assert!(
format!("{err}").contains("does not include `write`"),
"{err}"
);
authorize(
&room(Visibility::Open),
&presentation(2, false),
Action::Read,
PRESENTER,
NOW,
&Vouches::read_only(),
)
.await
.expect("but it reads");
}
#[tokio::test]
async fn no_action_implies_another() {
let err = authorize(
&room(Visibility::Open),
&presentation(1, false),
Action::Admin,
PRESENTER,
NOW,
&Vouches(vec!["read".into(), "write".into(), "curate".into()]),
)
.await
.unwrap_err();
assert!(
format!("{err}").contains("does not include `admin`"),
"{err}"
);
}
#[tokio::test]
async fn an_empty_chain_authorizes_nothing() {
let err = authorize(
&room(Visibility::Open),
&presentation(0, false),
Action::Read,
PRESENTER,
NOW,
&Vouches::for_all(),
)
.await
.unwrap_err();
assert!(format!("{err}").contains("no authority chain"), "{err}");
}
#[tokio::test]
async fn a_chain_past_the_ceiling_is_refused() {
let err = authorize(
&room(Visibility::Open),
&presentation(MAX_CHAIN_DEPTH + 1, false),
Action::Read,
PRESENTER,
NOW,
&Vouches::for_all(),
)
.await
.unwrap_err();
assert!(format!("{err}").contains("exceeding the maximum"), "{err}");
}
#[tokio::test]
async fn the_shape_checks_run_before_the_verifier() {
struct Panics;
#[async_trait::async_trait]
impl ChainVerifier for Panics {
async fn verify(
&self,
_: &Room,
_: &AuthorityPresentation,
_: Action,
_: &str,
) -> Result<VerifiedChain, AppError> {
panic!("the verifier must not be reached for a malformed presentation");
}
}
for p in [
presentation(0, false),
presentation(MAX_CHAIN_DEPTH + 1, false),
] {
assert!(
authorize(
&room(Visibility::Open),
&p,
Action::Read,
PRESENTER,
NOW,
&Panics
)
.await
.is_err()
);
}
}
#[tokio::test]
async fn a_private_room_refuses_a_presentation_with_no_subject_binding() {
let err = authorize(
&room(Visibility::Private),
&presentation(2, false),
Action::Read,
PRESENTER,
NOW,
&Vouches::for_all(),
)
.await
.unwrap_err();
assert!(format!("{err}").contains("subject binding"), "{err}");
}
#[tokio::test]
async fn a_host_with_no_verifier_authorizes_nothing() {
for v in [
Visibility::Open,
Visibility::Attributed,
Visibility::Private,
] {
let err = authorize(
&room(v),
&presentation(2, true),
Action::Read,
PRESENTER,
NOW,
&RefusesEverything,
)
.await
.unwrap_err();
assert!(
format!("{err}").contains("no chain verifier configured"),
"{v:?}: {err}"
);
}
}
#[tokio::test]
async fn an_unbound_presentation_is_refused() {
let err = authorize(
&room(Visibility::Open),
&presentation(2, false),
Action::Read,
" ",
NOW,
&Vouches::for_all(),
)
.await
.unwrap_err();
assert!(
format!("{err}").contains("no authenticated presenter"),
"{err}"
);
}
#[tokio::test]
async fn a_chain_granting_to_someone_else_is_refused() {
let err = authorize(
&room(Visibility::Open),
&presentation(2, false),
Action::Read,
PRESENTER,
NOW,
&VouchesForSomeoneElse,
)
.await
.unwrap_err();
assert!(
format!("{err}").contains("not to the party that signed this request"),
"{err}"
);
}
#[tokio::test]
async fn a_missing_membership_credential_is_refused() {
let mut p = presentation(2, false);
p.membership = " ".into();
let err = authorize(
&room(Visibility::Open),
&p,
Action::Read,
PRESENTER,
NOW,
&Vouches::for_all(),
)
.await
.unwrap_err();
assert!(
format!("{err}").contains("no membership credential"),
"{err}"
);
}
#[tokio::test]
async fn a_lapsed_room_refuses_writes_and_keeps_serving_reads() {
let mut r = room(Visibility::Open);
r.epoch_expires_at = Some(NOW - 1);
let err = authorize(
&r,
&presentation(2, false),
Action::Write,
PRESENTER,
NOW,
&Vouches::for_all(),
)
.await
.unwrap_err();
let text = format!("{err}");
assert!(text.contains("accepts no writes"), "{text}");
assert!(
text.contains("rooms/epoch/mint"),
"the refusal must say how to fix it: {text}"
);
authorize(
&r,
&presentation(2, false),
Action::Read,
PRESENTER,
NOW,
&Vouches::for_all(),
)
.await
.expect("a lapse hides nothing — reads keep working");
}
#[tokio::test]
async fn every_lapsed_state_still_accepts_the_operation_that_renews_it() {
let mut r = room(Visibility::Open);
r.retention_days = 90;
for (days_past_expiry, state) in [(1, "lapsed"), (31, "dormant"), (91, "reclaimable")] {
r.epoch_expires_at = Some(NOW - days_past_expiry * 24 * 60 * 60);
assert_eq!(
r.lifecycle(NOW).as_str(),
state,
"fixture should be {state}"
);
authorize(
&r,
&presentation(2, false),
Action::Admin,
PRESENTER,
NOW,
&Vouches::for_all(),
)
.await
.unwrap_or_else(|e| panic!("a {state} room must still accept a renewal: {e}"));
let err = authorize(
&r,
&presentation(2, false),
Action::Write,
PRESENTER,
NOW,
&Vouches::for_all(),
)
.await
.unwrap_err();
assert!(
format!("{err}").contains("accepts no writes"),
"a {state} room must refuse ordinary writes: {err}"
);
}
}
#[tokio::test]
async fn a_lapsed_room_refuses_curation() {
let mut r = room(Visibility::Open);
r.epoch_expires_at = Some(NOW - 1);
let err = authorize(
&r,
&presentation(2, false),
Action::Curate,
PRESENTER,
NOW,
&Vouches::for_all(),
)
.await
.unwrap_err();
assert!(format!("{err}").contains("accepts no writes"), "{err}");
}
#[test]
fn actions_are_distinct_wire_strings() {
assert_eq!(Action::Read.as_str(), "read");
assert_eq!(Action::Admin.as_str(), "admin");
assert_ne!(Action::Admin.as_str(), Action::Write.as_str());
}
}