diff --git a/knot2/crates/knot-acl/src/lib.rs b/knot2/crates/knot-acl/src/lib.rs index 178afdbc4..18409f369 100644 --- a/knot2/crates/knot-acl/src/lib.rs +++ b/knot2/crates/knot-acl/src/lib.rs @@ -191,6 +191,8 @@ impl Acl for KnotAcl<'_> { #[cfg(test)] mod tests { + use knot_types::OfferedKey; + use super::*; fn acc(suffix: &str) -> AccountDid { @@ -947,6 +949,41 @@ mod tests { "public repos stay open to anyone" ); + let offered = |seed: u8| OfferedKey::from_bytes(vec![seed]); + let lease = knot_index::KeyTtl::DEFAULT.lease_from(at(0)); + for (who, seed) in [("nel", 1), ("lyna", 2), ("teq", 3), ("olaren", 4)] { + index.keys().record(&acc(who), vec![offered(seed)], lease); + } + // a stranger can publish anyone's public key + index + .keys() + .record(&acc("olaren"), vec![offered(1), offered(4)], lease); + let reads_by_key = + |repo: &RepoDid, key: OfferedKey| reads(repo, Viewer::keyed(key, at(0))); + assert_eq!(reads_by_key(&squid, offered(2)), Resolved::Ready(true)); + assert_eq!( + reads_by_key(&squid, offered(1)), + Resolved::Ready(true), + "the owner's key reads even when a stranger publishes it too" + ); + for stranger in [offered(4), offered(3), offered(9)] { + assert_eq!( + reads_by_key(&squid, stranger), + reads(&repo("nobody"), Viewer::anonymous()), + "a key nobody on the repo publishes can't tell a private repo from a missing one" + ); + } + assert_eq!( + reads_by_key(&limpet, offered(9)), + Resolved::Ready(true), + "public repos stay open to any key" + ); + assert_eq!( + reads(&squid, Viewer::keyed(offered(2), at(7_200))), + Resolved::Ready(false), + "a lapsed key lease reads as a stranger" + ); + let cold = Index::new(&meta_path, layout); cold.rebuild().unwrap(); assert_eq!( diff --git a/knot2/crates/knot-acl/src/repos.rs b/knot2/crates/knot-acl/src/repos.rs index cdf87f129..613dddb6f 100644 --- a/knot2/crates/knot-acl/src/repos.rs +++ b/knot2/crates/knot-acl/src/repos.rs @@ -7,6 +7,7 @@ use knot_types::{ Visibility, }; +use crate::viewer::Who; use crate::{Acl, Viewer, can_push}; #[derive(Debug, Clone)] @@ -132,15 +133,28 @@ pub fn read_grant(index: &Index, repo: RepoDid, viewer: &Viewer) -> Resolved bool { - let Some(who) = viewer.account() else { - return false; + let owner = index.owner_of(repo).ready_or_default(); + let member = match viewer.who() { + Who::Anonymous => return false, + Who::Account(who) => { + let owns = owner.is_some_and(|owner| owner.is(who)); + let collaborates = matches!( + index.effective_collaborator(repo, who), + Resolved::Ready(true) + ); + (owns || collaborates).then(|| who.clone()) + } + Who::Key(key, now) => { + let collaborators = index.effective_collaborators(repo).ready_or_default(); + let candidates: Vec = owner + .map(AccountDid::from) + .into_iter() + .chain(collaborators) + .collect(); + index.keys().publisher_among(&candidates, key, *now) + } }; - let owns = matches!(index.owner_of(repo), Resolved::Ready(Some(owner)) if owner.is(who)); - let collaborates = matches!( - index.effective_collaborator(repo, who), - Resolved::Ready(true) - ); - (owns || collaborates) && matches!(index.is_blocked(who), Resolved::Ready(false)) + member.is_some_and(|who| matches!(index.is_blocked(&who), Resolved::Ready(false))) } pub fn read_grant_by_path( diff --git a/knot2/crates/knot-acl/src/viewer.rs b/knot2/crates/knot-acl/src/viewer.rs index dfc2a7e9e..6d1b64ba8 100644 --- a/knot2/crates/knot-acl/src/viewer.rs +++ b/knot2/crates/knot-acl/src/viewer.rs @@ -1,34 +1,57 @@ use knot_atproto::Authenticated; -use knot_types::AccountDid; +use knot_types::{AccountDid, OfferedKey, UnixSeconds}; -// who is asking. the only ways in are a verified service jwt or ssh naming a key's account, never a bare did +// only a verified jwt or an ssh login makes one and never a bare did #[derive(Debug, Clone, PartialEq, Eq)] -pub struct Viewer(Option); +pub struct Viewer(Who); + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum Who { + Anonymous, + Account(AccountDid), + // a key only proves possession and anyone can publish it so each repo decides who it means + Key(OfferedKey, UnixSeconds), +} impl Viewer { pub fn anonymous() -> Self { - Self(None) + Self(Who::Anonymous) + } + + // `check-no-forged-viewer` fails if anything but knot-ssh's exec calls this + pub fn ssh_identified(account: AccountDid) -> Self { + Self(Who::Account(account)) + } + + pub fn ssh_offered(key: OfferedKey, now: UnixSeconds) -> Self { + Self(Who::Key(key, now)) } pub fn account(&self) -> Option<&AccountDid> { - self.0.as_ref() + match &self.0 { + Who::Account(account) => Some(account), + Who::Anonymous | Who::Key(..) => None, + } } - // `check-no-forged-viewer` keeps this out of everything but knot-ssh's exec - pub fn ssh_identified(account: AccountDid) -> Self { - Self(Some(account)) + pub(crate) fn who(&self) -> &Who { + &self.0 } } #[cfg(test)] impl Viewer { pub(crate) fn of(account: AccountDid) -> Self { - Self(Some(account)) + Self(Who::Account(account)) + } + + pub(crate) fn keyed(key: OfferedKey, now: UnixSeconds) -> Self { + Self::ssh_offered(key, now) } } impl From for Viewer { fn from(proof: Authenticated) -> Self { - Self(Some(proof.into_did())) + Self(Who::Account(proof.into_did())) } } diff --git a/knot2/justfile b/knot2/justfile index 5bc9ca0bf..272e50d78 100644 --- a/knot2/justfile +++ b/knot2/justfile @@ -123,13 +123,13 @@ check-no-raw-repo-open: check-no-forged-viewer: #!/usr/bin/env bash set -euo pipefail - hits=$(grep -rn --include="*.rs" "Viewer::ssh_identified" crates/*/src | grep -v '^crates/knot-ssh/src/exec.rs:' | grep -v '^crates/knot-acl/src/viewer.rs:' || [ $? = 1 ]) + hits=$(grep -rn --include="*.rs" -E "Viewer::ssh_(identified|offered)" crates/*/src | grep -v '^crates/knot-ssh/src/exec.rs:' | grep -v '^crates/knot-acl/src/viewer.rs:' || [ $? = 1 ]) if [ -n "$hits" ]; then - echo "no-forged-viewer check failed: only knot-ssh's exec names a viewer from a bare account, everything else needs an auth proof" >&2 + echo "no-forged-viewer check failed: only knot-ssh's exec names a viewer from a bare account or key, everything else needs an auth proof" >&2 echo "$hits" >&2 exit 1 fi - echo "ok: no viewer is built from a bare account outside knot-ssh" + echo "ok: no viewer is built from a bare account or key outside knot-ssh" check-fuzz-targets-enumerated: #!/usr/bin/env bash