diff --git a/knot2/crates/knot-server/src/main.rs b/knot2/crates/knot-server/src/main.rs index ba16e2099..41ae5e101 100644 --- a/knot2/crates/knot-server/src/main.rs +++ b/knot2/crates/knot-server/src/main.rs @@ -590,6 +590,7 @@ async fn main() -> anyhow::Result<()> { atproto: Arc::clone(&atproto), secrets, entropy: Arc::new(OsEntropy), + invite_codes: Default::default(), ci_logs, admins, admission, diff --git a/knot2/crates/knot-sim/src/harness.rs b/knot2/crates/knot-sim/src/harness.rs index 269e87ccd..cbc568606 100644 --- a/knot2/crates/knot-sim/src/harness.rs +++ b/knot2/crates/knot-sim/src/harness.rs @@ -809,6 +809,7 @@ fn assemble_router(parts: StateParts) -> Router { atproto, secrets, entropy, + invite_codes: Default::default(), ci_logs: None, admins, admission, diff --git a/knot2/crates/knot-sim/tests/lfs_roundtrip.rs b/knot2/crates/knot-sim/tests/lfs_roundtrip.rs index 20b0285fb..f07e69892 100644 --- a/knot2/crates/knot-sim/tests/lfs_roundtrip.rs +++ b/knot2/crates/knot-sim/tests/lfs_roundtrip.rs @@ -335,6 +335,7 @@ async fn spawn(published_line: String, with_h3: bool) -> World { atproto, secrets, entropy: Arc::new(OsEntropy), + invite_codes: Default::default(), admins: BTreeSet::from([AccountDid::new(OWNER_DID).unwrap()]), admission: AdmissionPolicy::Closed, contribution_policy: knot_types::ContributionPolicy::Anyone, diff --git a/knot2/crates/knot-ssh/tests/ssh_push.rs b/knot2/crates/knot-ssh/tests/ssh_push.rs index 562c63fd3..8d00bd96e 100644 --- a/knot2/crates/knot-ssh/tests/ssh_push.rs +++ b/knot2/crates/knot-ssh/tests/ssh_push.rs @@ -394,6 +394,7 @@ async fn spawn_server_core( atproto: Arc::clone(&atproto), secrets, entropy: Arc::new(knot_runtime::OsEntropy), + invite_codes: Default::default(), ci_logs: None, admins: std::collections::BTreeSet::new(), admission: knot_types::AdmissionPolicy::Closed, diff --git a/knot2/crates/knot-xrpc/src/invite_codes.rs b/knot2/crates/knot-xrpc/src/invite_codes.rs new file mode 100644 index 000000000..44916c71f --- /dev/null +++ b/knot2/crates/knot-xrpc/src/invite_codes.rs @@ -0,0 +1,180 @@ +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; + +use axum::body::Bytes; +use axum::extract::State; +use axum::response::Response; +use http::{HeaderMap, StatusCode}; +use serde::Deserialize; + +use knot_cobs::{Accept, EntryState, Grant, MembersChange}; +use knot_index::Resolved; +use knot_runtime::{Clock, Entropy, HttpTransport}; +use knot_types::{KnotHostname, UnixSeconds}; + +use crate::cob::Authorized; +use crate::error::XrpcError; +use crate::members::commit_members; +use crate::{XrpcState, decode, folded_member_state, ok_empty}; + +pub(crate) const REDEEM_ROUTE: &str = "/xrpc/org.tangled.temp.server.redeemInviteCode"; + +pub const LANDING_PATH: &str = "/invite"; + +const TTL_SECS: i64 = 2 * 24 * 60 * 60; + +const CODE_BYTES: usize = 16; + +#[derive(Debug, Default)] +pub struct InviteCodes { + live: Mutex>, +} + +impl InviteCodes { + fn lock(&self) -> std::sync::MutexGuard<'_, HashMap> { + self.live + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + } + + pub fn mint(&self, entropy: &dyn Entropy, now: UnixSeconds) -> (String, UnixSeconds) { + let mut bytes = [0u8; CODE_BYTES]; + entropy.fill(&mut bytes); + let code: String = bytes.iter().map(|byte| format!("{byte:02x}")).collect(); + let expires_at = now.saturating_add_secs(TTL_SECS); + let mut live = self.lock(); + live.retain(|_, expiry| *expiry > now); + live.insert(code.clone(), expires_at); + (code, expires_at) + } + + pub fn is_live(&self, code: &str, now: UnixSeconds) -> bool { + self.lock().get(code).is_some_and(|expiry| *expiry > now) + } + + fn redeem(&self, code: &str, now: UnixSeconds) -> bool { + self.lock().remove(code).is_some_and(|expiry| expiry > now) + } +} + +pub(crate) fn invite_url(hostname: &KnotHostname, code: &str) -> String { + format!("https://{}{LANDING_PATH}/{code}", hostname.as_str()) +} + +#[derive(Deserialize)] +struct CodeInput { + code: String, +} + +pub(crate) async fn redeem_invite_code( + State(state): State>>, + headers: HeaderMap, + method: crate::Method, + body: Bytes, +) -> Result { + let actor = state.authenticate_write(&headers, &method).await?; + let CodeInput { code } = decode(&body)?; + + let now = state.now(); + if !state.invite_codes.redeem(&code, now) { + return Err(XrpcError::named( + StatusCode::BAD_REQUEST, + "InviteNotFound", + "this invite code is unknown, already spent, or expired", + )); + } + + let change = match folded_member_state(&state, &actor).await? { + Resolved::Warming => { + return Err(XrpcError::warming( + "member projection is still warming, please retry shortly", + )); + } + Resolved::Ready(Some(state)) if state.is_effective() => return Ok(ok_empty()), + Resolved::Ready(Some(EntryState::Invited)) => MembersChange::Accept(Accept { + subject: actor.clone(), + verified_at: now, + }), + Resolved::Ready(_) => MembersChange::Add(Grant { + subject: actor.clone(), + added_by: state.service_owner.clone(), + created_at: now, + }), + }; + + tracing::info!(route = REDEEM_ROUTE, %actor, "an invite code was redeemed"); + commit_members(&state, Authorized::by_decree(change), now).await +} + +#[cfg(test)] +mod tests { + use super::*; + + use knot_runtime::SeededEntropy; + + fn at(seconds: i64) -> UnixSeconds { + UnixSeconds::new(seconds) + } + + fn entropy() -> SeededEntropy { + SeededEntropy::new(7) + } + + #[test] + fn a_code_redeems_once_and_never_again() { + let links = InviteCodes::default(); + let (code, expires_at) = links.mint(&entropy(), at(1_000)); + + assert_eq!( + expires_at, + at(1_000 + TTL_SECS), + "a fresh code expires two days out" + ); + assert!(links.is_live(&code, at(1_000)), "a fresh code is live"); + assert!(links.redeem(&code, at(1_000)), "the first redeem spends it"); + assert!( + !links.redeem(&code, at(1_000)), + "a spent code must not admit a second account" + ); + assert!(!links.is_live(&code, at(1_000)), "a spent code is not live"); + } + + #[test] + fn a_code_past_its_expiry_neither_reads_as_live_nor_redeems() { + let links = InviteCodes::default(); + let (code, expires_at) = links.mint(&entropy(), at(1_000)); + let expired = expires_at.saturating_add_secs(1); + + assert!(!links.is_live(&code, expired)); + assert!(!links.redeem(&code, expired)); + } + + #[test] + fn minting_sweeps_codes_that_have_already_expired() { + let links = InviteCodes::default(); + // one source across both mints: a fresh SeededEntropy would replay the + // same bytes and the "stale" code would be the one just reinserted + let source = entropy(); + let (stale, stale_expiry) = links.mint(&source, at(1_000)); + let later = stale_expiry.saturating_add_secs(1); + + let (fresh, _) = links.mint(&source, later); + assert!( + !links.lock().contains_key(&stale), + "minting left an expired code in the map" + ); + assert!(links.is_live(&fresh, later)); + } + + #[test] + fn two_codes_from_one_source_differ_and_are_full_length() { + let links = InviteCodes::default(); + let source = entropy(); + let (first, _) = links.mint(&source, at(1)); + let (second, _) = links.mint(&source, at(1)); + + assert_ne!(first, second, "a repeated code would admit twice"); + assert_eq!(first.len(), CODE_BYTES * 2, "the code is hex over 128 bits"); + assert!(first.chars().all(|c| c.is_ascii_hexdigit())); + } +} diff --git a/knot2/crates/knot-xrpc/src/legacy_admin.rs b/knot2/crates/knot-xrpc/src/legacy_admin.rs index 92fa3d019..6afc44f87 100644 --- a/knot2/crates/knot-xrpc/src/legacy_admin.rs +++ b/knot2/crates/knot-xrpc/src/legacy_admin.rs @@ -1,12 +1,13 @@ use std::sync::Arc; -use axum::Router; use axum::body::Bytes; use axum::extract::{DefaultBodyLimit, State}; use axum::middleware::from_fn_with_state; -use axum::response::Response; +use axum::response::{IntoResponse, Response}; use axum::routing::post; +use axum::{Json, Router}; use http::HeaderMap; +use serde_json::json; use subtle::ConstantTimeEq; use zeroize::Zeroizing; @@ -14,10 +15,12 @@ use knot_cobs::Grant; use knot_runtime::{Clock, HttpTransport}; use crate::error::XrpcError; +use crate::invite_codes::invite_url; use crate::members::{SubjectInput, offer_membership}; use crate::{XrpcState, basic_credentials, decode, enforce_pre_auth_limit}; pub const ADD_MEMBER_ROUTE: &str = "/admin/addMember"; +pub const CREATE_INVITE_CODE_ROUTE: &str = "/xrpc/org.tangled.temp.server.createInviteCode"; const BASIC_USER: &str = "admin"; @@ -65,6 +68,7 @@ pub fn router( let limiter = Arc::clone(&state); Router::new() .route(ADD_MEMBER_ROUTE, post(add_member::)) + .route(CREATE_INVITE_CODE_ROUTE, post(create_invite_code::)) .layer(DefaultBodyLimit::max(limits)) .layer(from_fn_with_state(limiter, enforce_pre_auth_limit::)) .with_state(Arc::new(LegacyAdmin { state, secret })) @@ -93,6 +97,32 @@ async fn add_member( .await } +fn rfc3339(at: knot_types::UnixSeconds) -> Result { + chrono::DateTime::from_timestamp(at.get(), 0) + .map(|at| at.to_rfc3339_opts(chrono::SecondsFormat::Secs, true)) + .ok_or_else(|| XrpcError::internal("invite expiry is outside the rfc3339 range")) +} + +async fn create_invite_code( + State(admin): State>>, + headers: HeaderMap, +) -> Result { + admin.secret.authorize(&headers)?; + let state = &admin.state; + let (code, expires_at) = state.invite_codes.mint(&*state.entropy, state.now()); + tracing::info!( + route = CREATE_INVITE_CODE_ROUTE, + %expires_at, + "minted an invite code" + ); + Ok(Json(json!({ + "code": code, + "url": invite_url(&state.knot_hostname, &code), + "expiresAt": rfc3339(expires_at)?, + })) + .into_response()) +} + #[cfg(test)] mod tests { use super::*; diff --git a/knot2/crates/knot-xrpc/src/lib.rs b/knot2/crates/knot-xrpc/src/lib.rs index 69508262e..1b7b9a9a7 100644 --- a/knot2/crates/knot-xrpc/src/lib.rs +++ b/knot2/crates/knot-xrpc/src/lib.rs @@ -9,6 +9,7 @@ mod error; mod firehose; mod forks; mod git; +pub mod invite_codes; pub mod legacy_admin; mod lfs; mod lists; @@ -34,6 +35,7 @@ mod tests; pub use error::XrpcError; pub use firehose::{FirehoseEmit, persist_seq, replay_floor as firehose_floor}; +pub use invite_codes::InviteCodes; pub use knot_pack::MaxWireBytes; pub use knot_resource::{ Burst, GlobalInflight, LimitConfig, PerPeerInflight, PreAuthLimiter, RateLimit, RefillMicros, @@ -174,6 +176,7 @@ pub struct XrpcState { pub atproto: Arc>, pub secrets: Arc, pub entropy: Arc, + pub invite_codes: Arc, pub admins: BTreeSet, pub admission: AdmissionPolicy, pub contribution_policy: ContributionPolicy, @@ -327,6 +330,10 @@ pub fn router(state: Arc>) -> Router members::ACCEPT_ROUTE, post(members::accept_membership::), ) + .route( + invite_codes::REDEEM_ROUTE, + post(invite_codes::redeem_invite_code::), + ) .route(blocklist::BAN_ROUTE, post(blocklist::ban::)) .route(blocklist::UNBAN_ROUTE, post(blocklist::unban::)) .route( diff --git a/knot2/crates/knot-xrpc/src/members.rs b/knot2/crates/knot-xrpc/src/members.rs index 1d78679b4..cbe2a74ab 100644 --- a/knot2/crates/knot-xrpc/src/members.rs +++ b/knot2/crates/knot-xrpc/src/members.rs @@ -68,7 +68,7 @@ pub(crate) async fn offer_membership( .await } -async fn commit_members( +pub(crate) async fn commit_members( state: &Arc>, change: Authorized, now: UnixSeconds, diff --git a/knot2/crates/knot-xrpc/src/tests.rs b/knot2/crates/knot-xrpc/src/tests.rs index 8a028840f..0e954f80f 100644 --- a/knot2/crates/knot-xrpc/src/tests.rs +++ b/knot2/crates/knot-xrpc/src/tests.rs @@ -342,6 +342,7 @@ fn state_from( atproto, secrets, entropy: Arc::new(OsEntropy), + invite_codes: Default::default(), ci_logs: None, admins: BTreeSet::from([account(ADMIN_HOST)]), admission, @@ -2199,6 +2200,114 @@ async fn member_lifecycle() { ); } +fn mint_invite(world: &World) -> String { + let state = world.state(); + let (code, _) = state.invite_codes.mint(&*state.entropy, state.now()); + code +} + +async fn redeem(world: &World, actor: &Actor, code: &str) -> StatusCode { + call( + world, + crate::invite_links::redeem_invite_code, + actor, + // derived, never spelled twice: these tests call the handler directly, so + // a token minted for the wrong nsid would still agree with itself + crate::invite_links::REDEEM_ROUTE + .strip_prefix("/xrpc/") + .expect("xrpc route paths are prefixed"), + json!({ "code": code }), + ) + .await + .status() +} + +#[tokio::test] +async fn redeeming_an_invite_link_admits_the_token_issuer_and_spends_the_code() { + let world = World::new(); + let code = mint_invite(&world); + let stranger = account(STRANGER_HOST); + + assert_eq!( + world.state.index.effective_member(&stranger), + Resolved::Ready(false), + "the stranger starts outside the knot" + ); + assert_eq!(redeem(&world, &world.stranger, &code).await, StatusCode::OK); + assert_eq!( + world.state.index.effective_member(&stranger), + Resolved::Ready(true), + "redeeming admits the account that signed the token, with no acceptance round trip" + ); + + assert_eq!( + redeem(&world, &world.passerby, &code).await, + StatusCode::BAD_REQUEST, + "a spent code must not admit a second account" + ); + assert_eq!( + world.state.index.effective_member(&account(PASSERBY_HOST)), + Resolved::Ready(false) + ); +} + +#[tokio::test] +async fn an_unknown_code_admits_nobody() { + let world = World::new(); + assert_eq!( + redeem(&world, &world.stranger, "not-a-code").await, + StatusCode::BAD_REQUEST + ); + assert_eq!( + world.state.index.effective_member(&account(STRANGER_HOST)), + Resolved::Ready(false) + ); +} + +#[tokio::test] +async fn a_link_closes_out_an_invite_the_admin_had_already_offered() { + let world = World::new(); + let member = account(MEMBER_HOST); + assert_eq!(offer(&world, None, &member).await, StatusCode::OK); + assert_eq!( + world.state.index.member_state(&member), + Resolved::Ready(Some(EntryState::Invited)), + "addMember leaves the account standing Invited" + ); + + let code = mint_invite(&world); + assert_eq!( + redeem(&world, &world.member, &code).await, + StatusCode::OK, + "a bare grant over a pending invite would be a 409, which no one holding a \ + valid link should ever see" + ); + assert_eq!( + world.state.index.effective_member(&member), + Resolved::Ready(true) + ); +} + +#[tokio::test] +async fn redeeming_twice_as_an_existing_member_is_a_no_op() { + let world = World::new(); + let first = mint_invite(&world); + let second = mint_invite(&world); + assert_eq!( + redeem(&world, &world.stranger, &first).await, + StatusCode::OK + ); + assert_eq!( + redeem(&world, &world.stranger, &second).await, + StatusCode::OK, + "an account already inside stays inside rather than conflicting" + ); + assert_eq!( + world.state.index.effective_member(&account(STRANGER_HOST)), + Resolved::Ready(true) + ); +} + #[tokio::test] async fn add_member_auth_outcomes() { struct Case { @@ -5061,6 +5170,22 @@ mod rolls { ); } + #[tokio::test] + async fn redeeming_an_invite_link_publishes_no_record_at_all() { + let world = World::new(); + let state = world.state(); + let (code, _) = state.invite_codes.mint(&*state.entropy, state.now()); + assert_eq!(redeem(&world, &world.stranger, &code).await, StatusCode::OK); + + assert!( + members_changes(&world) + .iter() + .all(|change| change.record.bytes().is_none()), + "a link admits by bare grant, so the knot holds the list and nothing \ + reaches atproto" + ); + } + #[tokio::test] async fn a_grandfathered_acceptance_mints_the_lazy_invite_in_the_same_change() { let world = World::new(); diff --git a/knot2/crates/knot-xrpc/tests/common/mod.rs b/knot2/crates/knot-xrpc/tests/common/mod.rs index c55ab2f80..d154599ad 100644 --- a/knot2/crates/knot-xrpc/tests/common/mod.rs +++ b/knot2/crates/knot-xrpc/tests/common/mod.rs @@ -298,6 +298,7 @@ impl World { atproto, secrets, entropy: Arc::new(OsEntropy), + invite_codes: Default::default(), ci_logs: None, admins, admission: knot_types::AdmissionPolicy::Closed,