diff --git a/capsule-android/src/androidMain/res/values/strings.xml b/capsule-android/src/androidMain/res/values/strings.xml index e8f2144f..112f16fd 100644 --- a/capsule-android/src/androidMain/res/values/strings.xml +++ b/capsule-android/src/androidMain/res/values/strings.xml @@ -1885,9 +1885,13 @@ This shared album\'s access could not be verified. Access to this shared album has been revoked. This source is temporarily backed off after repeated errors. + That person isn\'t on this album\'s member list. + This server doesn\'t share albums with other servers. + That server isn\'t one this server knows. This source has reached its request limit. Please wait and try again. Capsule couldn\'t read the revocation list. Please try again. This access grant does not cover the requested content. + Capsule couldn\'t reach the federation records. Please try again. Your account is suspended. You can\'t upload or share until it\'s reinstated. Too many reports from this source. Please wait and try again. The moderation report could not be verified. diff --git a/capsule-docs/planned-modules.txt b/capsule-docs/planned-modules.txt index d78427f0..f5c907d9 100644 --- a/capsule-docs/planned-modules.txt +++ b/capsule-docs/planned-modules.txt @@ -15,4 +15,3 @@ capsule-core::media The Capsule-side owner of decode, metadata extraction and derivative generation, which will consume Rawshift once Rawshift stabilizes. Rawshift is a pinned submodule today and is not a workspace dependency, so nothing consumes it and this module has no body to write yet. Lane B in SLICES.md. capsule-core::notify Alert classes and their trigger predicates, so every platform evaluates one shared decision function rather than reimplementing the taxonomy. Contract: design/notifications.md. Tier 0 has no server half, so this is client-only work. capsule-core::import::camera The PTP/IP tethered-camera source adapter (S-B9). Post-v1; the contract exists so the adapter seam is fixed before anything implements it. -capsule-server::federation Server-to-server federation pull. The whole surface is post-v1 — `capsule-server` has no federation route, no capability-token verifier and no per-peer budget enforcement. diff --git a/capsule-i18n/src/bundles/en.json b/capsule-i18n/src/bundles/en.json index 3c02cc0c..49ae417f 100644 --- a/capsule-i18n/src/bundles/en.json +++ b/capsule-i18n/src/bundles/en.json @@ -1894,9 +1894,13 @@ "error.federation.capability_invalid": "This shared album's access could not be verified.", "error.federation.capability_revoked": "Access to this shared album has been revoked.", "error.federation.circuit_open": "This source is temporarily backed off after repeated errors.", + "error.federation.member_not_on_roster": "That person isn't on this album's member list.", + "error.federation.not_configured": "This server doesn't share albums with other servers.", + "error.federation.peer_unknown": "That server isn't one this server knows.", "error.federation.rate_budget_exceeded": "This source has reached its request limit. Please wait and try again.", "error.federation.revocations_unavailable": "Capsule couldn't read the revocation list. Please try again.", "error.federation.scope_insufficient": "This access grant does not cover the requested content.", + "error.federation.unavailable": "Capsule couldn't reach the federation records. Please try again.", "error.moderation.account_suspended": "Your account is suspended. You can't upload or share until it's reinstated.", "error.moderation.report_rate_limited": "Too many reports from this source. Please wait and try again.", "error.moderation.report_unsigned": "The moderation report could not be verified.", diff --git a/capsule-i18n/src/generated.rs b/capsule-i18n/src/generated.rs index 1786d817..7ce5103a 100644 --- a/capsule-i18n/src/generated.rs +++ b/capsule-i18n/src/generated.rs @@ -236,6 +236,15 @@ pub mod error_codes { /// `error.federation.circuit_open` pub const FEDERATION_CIRCUIT_OPEN: &str = "error.federation.circuit_open"; + /// `error.federation.member_not_on_roster` + pub const FEDERATION_MEMBER_NOT_ON_ROSTER: &str = "error.federation.member_not_on_roster"; + + /// `error.federation.not_configured` + pub const FEDERATION_NOT_CONFIGURED: &str = "error.federation.not_configured"; + + /// `error.federation.peer_unknown` + pub const FEDERATION_PEER_UNKNOWN: &str = "error.federation.peer_unknown"; + /// `error.federation.rate_budget_exceeded` pub const FEDERATION_RATE_BUDGET_EXCEEDED: &str = "error.federation.rate_budget_exceeded"; @@ -245,6 +254,9 @@ pub mod error_codes { /// `error.federation.scope_insufficient` pub const FEDERATION_SCOPE_INSUFFICIENT: &str = "error.federation.scope_insufficient"; + /// `error.federation.unavailable` + pub const FEDERATION_UNAVAILABLE: &str = "error.federation.unavailable"; + /// `error.moderation.account_suspended` pub const MODERATION_ACCOUNT_SUSPENDED: &str = "error.moderation.account_suspended"; diff --git a/capsule-server/.env.example b/capsule-server/.env.example index c94a8ace..49ef106b 100644 --- a/capsule-server/.env.example +++ b/capsule-server/.env.example @@ -56,6 +56,14 @@ SERVER_DOMAIN=localhost # Default: http://{SERVER_DOMAIN}:{SERVER_PORT}/v1 # API_BASE_URL=https://api.capsule.example/v1 +# Where federated peers pull from, published as `server-info.federation_url`. Federation reuses +# the versioned API itself — `GET /v1/sync?album_id=` and `GET /v1/blob/{hash}` under a capability +# bearer — so the value is this deployment's API base URL. **Unset means this server does not +# federate**: the record publishes no endpoint, and minting, refreshing or revoking a capability +# and federated report intake all refuse with `error.federation.not_configured`. A capability +# minted while it was set still verifies; configuration does not un-mint a token. +# FEDERATION_URL=https://api.capsule.example/v1 + # TLS is **not** terminated here. `design/cryptography/failure-modes.md` puts HTTPS on the # ingress or reverse proxy; there is no certificate setting and Kynos's `tls` feature is off. diff --git a/capsule-server/src/app.rs b/capsule-server/src/app.rs index dbc6af14..b10094e1 100644 --- a/capsule-server/src/app.rs +++ b/capsule-server/src/app.rs @@ -37,6 +37,7 @@ use crate::discovery::DiscoveryContext; use crate::drop::DropContext; use crate::enrollment::EnrollmentContext; use crate::escrow::EscrowContext; +use crate::federation::FederationContext; use crate::membership::MembershipContext; use crate::moderation::ModerationContext; use crate::quota::QuotaContext; @@ -84,6 +85,8 @@ pub struct App { discovery: DiscoveryContext, /// The master-key escrow's collaborators. escrow: EscrowContext, + /// The federation module's collaborators (`S-E2`, `S-E5`). + federation: FederationContext, /// The cross-device add's collaborators. enrollment: EnrollmentContext, /// The moderation record's collaborators. @@ -131,6 +134,8 @@ pub struct Modules { pub discovery: DiscoveryContext, /// The master-key escrow's collaborators. pub escrow: EscrowContext, + /// The federation module's collaborators (`S-E2`, `S-E5`). + pub federation: FederationContext, /// The cross-device add's collaborators. pub enrollment: EnrollmentContext, /// The moderation record's collaborators. @@ -160,6 +165,7 @@ impl App { attestation, discovery, escrow, + federation, enrollment, moderation, share, @@ -180,6 +186,7 @@ impl App { attestation, discovery, escrow, + federation, enrollment, moderation, share, diff --git a/capsule-server/src/boot.rs b/capsule-server/src/boot.rs index a1825fa9..0920b128 100644 --- a/capsule-server/src/boot.rs +++ b/capsule-server/src/boot.rs @@ -65,11 +65,14 @@ use crate::blob::FilesystemBlobStore; use crate::config::{Backends, Config}; use crate::counter::{CounterContext, InMemoryCounters}; use crate::directory::{DeviceDirectoryContext, InMemoryDeviceDirectory}; -use crate::discovery::revocation::InMemoryRevocations; use crate::discovery::{DiscoveryContext, ProtocolWindow, ServerInfo}; use crate::drop::{DropContext, InMemoryDrops}; use crate::enrollment::EnrollmentContext; use crate::escrow::{EscrowContext, InMemoryEscrow}; +use crate::federation::{ + CapabilityCodec, FederationCollaborators, FederationContext, InMemoryCapabilities, + InMemoryPeers, +}; use crate::gc::CollectionContext; use crate::gc::memory::InMemoryCollection; use crate::index::memory::InMemoryAssetIndex; @@ -470,7 +473,22 @@ fn memory(config: &Config, stores: Stores) -> Result { capsule_core::crypto::keys::HybridSigningKey::from_seed64(&seed), )); - let server_info = Arc::new(ServerInfo::new( + // The capability codec signs with the **same** key: a peer verifies a capability against + // the key `server-info` publishes, and that key is read out of the session signer. Built + // from the same bytes rather than handed the signer, so the two stay one key by + // construction; `tests::the_capability_codec_signs_under_the_published_key` asserts it. + let capabilities = Arc::new( + CapabilityCodec::from_pkcs8(der.expose(), config.server_domain.clone(), clock.clone()) + .map_err(|error| BootError::SigningKey { + detail: error.detail, + })?, + ); + // The capability store **is** the revocation list `revoked-jti` serves: one object, handed + // to discovery as the list and to federation as the store (design/federation.md). + let issued = Arc::new(InMemoryCapabilities::new(clock.clone())); + let peers = Arc::new(InMemoryPeers::new()); + + let mut server_info = ServerInfo::new( config.server_domain.clone(), config.api_base_url.clone(), ProtocolWindow { @@ -478,7 +496,11 @@ fn memory(config: &Config, stores: Stores) -> Result { max: config.protocol_max.clone(), }, tokens.public_key().to_vec(), - )); + ); + if let Some(url) = &config.federation_url { + server_info = server_info.with_federation(url.clone()); + } + let server_info = Arc::new(server_info); let app = App::new(Modules { auth: AuthContext::new(AuthCollaborators { @@ -540,11 +562,15 @@ fn memory(config: &Config, stores: Stores) -> Result { // Publishing a rotation history is `ATTESTATION_KEY_HISTORY`'s job and nobody's yet. Timestamp::UNIX_EPOCH, ), - discovery: DiscoveryContext::new( - server_info, - Arc::new(InMemoryRevocations::new(clock.clone())), - ), + discovery: DiscoveryContext::new(server_info, issued.clone()), escrow: EscrowContext::new(Arc::new(InMemoryEscrow::new()), clock.clone()), + federation: FederationContext::new(FederationCollaborators { + codec: capabilities, + capabilities: issued, + peers, + clock: clock.clone(), + federation_url: config.federation_url.clone(), + }), enrollment: EnrollmentContext::new( Arc::new(InMemoryEnrollments::with_default_ttl(clock.clone())), Arc::new(InMemoryChannels::with_default_ttl(clock.clone())), @@ -920,6 +946,67 @@ mod tests { ); } + #[tokio::test] + async fn the_capability_codec_signs_under_the_published_key() { + // A peer verifies a capability against `server-info`'s `signing_key`. The codec is + // built from the same DER as the session signer, so the two are one key — asserted + // through the surface and through the codec, rather than assumed from the wiring. + let root = tempfile::tempdir().expect("a scratch directory"); + let config = memory_config(root.path()); + let assembled = assemble(&config).await.expect("it assembles"); + let client = kynos::test::TestClient::new(assembled.service().expect("the router builds")); + let body: serde_json::Value = client + .get("/.well-known/capsule/server-info") + .header("accept", "application/json") + .send() + .await + .assert_status(kynos::http::StatusCode::OK) + .json(); + let published = body["signing_key"].as_str().expect("it is published"); + let codec = crate::federation::CapabilityCodec::from_pkcs8( + config + .signing_key_der + .as_ref() + .expect("the key is configured") + .expose(), + config.server_domain.clone(), + std::sync::Arc::new(crate::store::SystemClock), + ) + .expect("the key parses"); + assert_eq!( + published, + base64::Engine::encode( + &base64::engine::general_purpose::STANDARD, + codec.public_key() + ) + ); + assert_eq!(codec.server_id(), config.server_domain); + assert!( + body.get("federation_url").is_none(), + "a deployment without FEDERATION_URL publishes no federation endpoint" + ); + } + + #[tokio::test] + async fn federation_url_is_published_when_configured() { + // Opt-in by one variable, and the record is the only way a peer learns it. + let root = tempfile::tempdir().expect("a scratch directory"); + let config = memory_config_with( + root.path(), + &[("FEDERATION_URL", "https://capsule.example/v1")], + ); + let assembled = assemble(&config).await.expect("it assembles"); + let client = kynos::test::TestClient::new(assembled.service().expect("the router builds")); + let body: serde_json::Value = client + .get("/.well-known/capsule/server-info") + .header("accept", "application/json") + .send() + .await + .assert_status(kynos::http::StatusCode::OK) + .json(); + assert_eq!(body["federation_url"], "https://capsule.example/v1"); + } + #[tokio::test] async fn the_published_protocol_window_is_the_configured_one() { let root = tempfile::tempdir().expect("a scratch directory"); diff --git a/capsule-server/src/config.rs b/capsule-server/src/config.rs index 4d8a1fce..b7ac5f09 100644 --- a/capsule-server/src/config.rs +++ b/capsule-server/src/config.rs @@ -301,6 +301,12 @@ pub struct Config { pub server_domain: String, /// The absolute base URL clients reach the versioned API at. pub api_base_url: String, + /// Where federated peers reach this server, when it federates at all (`FEDERATION_URL`). + /// + /// `None` is a deployment that does not federate: `server-info` publishes no + /// `federation_url`, and the capability lifecycle writes refuse with + /// `error.federation.not_configured`. + pub federation_url: Option, /// The filesystem tree ciphertext blobs are written to. There is no object store. pub blob_root: Option, /// The Postgres URL, once an adapter reads it (#402). @@ -396,6 +402,10 @@ impl Config { let api_base_url = env .var("API_BASE_URL") .unwrap_or_else(|| format!("http://{server_domain}:{}/v1", listen.port())); + // Opt-in, and the value is the URL peers pull from: the federation surface is the + // versioned API itself (design/federation.md, "no new data protocol"), so a deployment + // that federates publishes its API base here. + let federation_url = env.var("FEDERATION_URL"); // ── Storage ───────────────────────────────────────────────────────────────────── // `UPLOAD_DIR` is the name the retired deployment used, accepted so an operator's @@ -614,6 +624,7 @@ impl Config { listen, server_domain, api_base_url, + federation_url, blob_root, database_url, valkey_url, diff --git a/capsule-server/src/discovery/revocation.rs b/capsule-server/src/discovery/revocation.rs index 02ccc6f0..108b7bbf 100644 --- a/capsule-server/src/discovery/revocation.rs +++ b/capsule-server/src/discovery/revocation.rs @@ -25,15 +25,22 @@ //! somebody has to enforce. [`RevocationList::revoke`] refuses an entry whose expiry is beyond //! the ceiling, which is what keeps that reasoning true: one accepted long-lived entry and the //! list grows without bound while the peer-side staleness math silently stops applying. +//! +//! # Where the list lives now +//! +//! The port is implemented by the federation capability store +//! ([`crate::federation::CapabilityStore`]), because once this server *issues* capabilities the +//! record of one and the fact of its revocation are one row, and a standalone list would be a +//! second answer to "is this `jti` revoked". The deterministic adapter is +//! [`crate::federation::InMemoryCapabilities`]; the conformance suite that pins the pruning, +//! ordering and ceiling rules is `federation::conformance`. -use std::collections::BTreeMap; use std::fmt; use std::pin::Pin; -use std::sync::{Arc, Mutex}; use jiff::{SignedDuration, Timestamp}; -use crate::store::{Clock, StoreError, StoreFuture}; +use crate::store::{StoreError, StoreFuture}; /// The ceiling design/federation.md puts on a capability token's lifetime. pub const MAX_TOKEN_TTL: SignedDuration = SignedDuration::from_hours(24); @@ -122,82 +129,6 @@ pub trait RevocationList: fmt::Debug + Send + Sync { fn published(&self) -> StoreFuture<'_, PublishedRevocations>; } -/// The deterministic in-memory adapter. -#[derive(Debug)] -pub struct InMemoryRevocations { - entries: Mutex>, - clock: Arc, -} - -impl InMemoryRevocations { - /// An empty list reading `clock` for pruning and for `generated_at`. - pub fn new(clock: Arc) -> Self { - Self { - entries: Mutex::new(BTreeMap::new()), - clock, - } - } -} - -impl RevocationList for InMemoryRevocations { - fn revoke(&self, token: RevokedToken) -> RevokeFuture<'_> { - Box::pin(async move { - let now = self.clock.now(); - let ceiling = crate::store::deadline(now, MAX_TOKEN_TTL); - if token.expires_at > ceiling { - tracing::warn!( - jti = %token.jti, - expires_at = %token.expires_at, - "a revocation was refused: its expiry is beyond the capability TTL ceiling" - ); - return Err(RevocationError::BeyondTtlCeiling { - expires_at: token.expires_at, - ceiling: MAX_TOKEN_TTL, - } - .into()); - } - - let mut entries = self - .entries - .lock() - .expect("the revocation list is not poisoned"); - entries.insert(token.jti.clone(), token.expires_at); - tracing::info!( - jti = %token.jti, - expires_at = %token.expires_at, - published = entries.len(), - "a federation capability token was revoked" - ); - Ok(()) - }) - } - - fn published(&self) -> StoreFuture<'_, PublishedRevocations> { - Box::pin(async move { - let now = self.clock.now(); - let mut entries = self - .entries - .lock() - .expect("the revocation list is not poisoned"); - // Pruned on read *and* retained pruned, so a list nobody fetches does not grow - // forever holding entries that already mean nothing. - entries.retain(|_, expires_at| *expires_at > now); - let mut revoked: Vec = entries - .iter() - .map(|(jti, expires_at)| RevokedToken { - jti: jti.clone(), - expires_at: *expires_at, - }) - .collect(); - revoked.sort_by_key(|token| (token.expires_at, token.jti.clone())); - Ok(PublishedRevocations { - generated_at: now, - revoked, - }) - }) - } -} - /// What a verifier concluded about one `jti`. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum RevocationVerdict { diff --git a/capsule-server/src/discovery/tests.rs b/capsule-server/src/discovery/tests.rs index 540f84f4..e3b7c3b9 100644 --- a/capsule-server/src/discovery/tests.rs +++ b/capsule-server/src/discovery/tests.rs @@ -1,19 +1,15 @@ //! The registry's remaining records, and the rule a peer reads them by. -use std::sync::Arc; - use jiff::{SignedDuration, Timestamp}; use super::revocation::{ - InMemoryRevocations, MAX_STALENESS, MAX_TOKEN_TTL, PublishedRevocations, RevocationList, - RevocationVerdict, RevokeError, RevokedToken, check_revocation, + MAX_STALENESS, MAX_TOKEN_TTL, PublishedRevocations, RevocationVerdict, RevokedToken, + check_revocation, }; use super::{ AnnouncementError, DEFAULT_ANNOUNCEMENT_WINDOW, DeprecationAnnouncement, ProtocolWindow, ServerInfo, }; -use crate::store::Clock; -use crate::store::memory::ManualClock; fn window() -> ProtocolWindow { ProtocolWindow { @@ -127,90 +123,6 @@ fn a_cutoff_in_the_past_is_refused_as_such() { assert!(matches!(error, AnnouncementError::AlreadyPassed { .. })); } -#[tokio::test] -async fn a_revocation_beyond_the_ttl_ceiling_is_refused() { - // The published list is bounded *because* a capability token cannot outlive 24 hours. One - // accepted long-lived entry and the list grows without bound while the peer-side staleness - // math silently stops applying — so the ceiling is the port's invariant, not a convention. - let clock = Arc::new(ManualClock::default()); - let list = InMemoryRevocations::new(clock.clone()); - - let error = list - .revoke(RevokedToken { - jti: "beyond".to_owned(), - expires_at: crate::store::deadline(clock.now(), SignedDuration::from_hours(25)), - }) - .await - .expect_err("an entry past the ceiling is refused"); - - assert!(matches!(error, RevokeError::Refused(_))); - let published = list.published().await.expect("the list reads back"); - assert!(published.revoked.is_empty()); -} - -#[tokio::test] -async fn revoking_the_same_token_twice_is_one_entry() { - let clock = Arc::new(ManualClock::default()); - let list = InMemoryRevocations::new(clock.clone()); - let entry = RevokedToken { - jti: "repeated".to_owned(), - expires_at: crate::store::deadline(clock.now(), SignedDuration::from_hours(1)), - }; - - list.revoke(entry.clone()).await.expect("first revocation"); - list.revoke(entry).await.expect("a retry is not a new fact"); - - let published = list.published().await.expect("the list reads back"); - assert_eq!(published.revoked.len(), 1); -} - -#[tokio::test] -async fn an_entry_is_pruned_once_the_token_it_names_has_expired() { - // An expired token is rejected whether or not it appears here, so the entry carries no - // information — and dropping it is what keeps the list bounded by 24 hours of revocations. - let clock = Arc::new(ManualClock::default()); - let list = InMemoryRevocations::new(clock.clone()); - list.revoke(RevokedToken { - jti: "short".to_owned(), - expires_at: crate::store::deadline(clock.now(), SignedDuration::from_hours(1)), - }) - .await - .expect("revocation recorded"); - - assert_eq!( - list.published().await.expect("reads back").revoked.len(), - 1, - "live while the token it names could still be presented" - ); - - clock.advance(SignedDuration::from_hours(2)); - let published = list.published().await.expect("reads back"); - assert!(published.revoked.is_empty()); - assert_eq!(published.generated_at, clock.now()); -} - -#[tokio::test] -async fn the_published_list_orders_by_expiry() { - let clock = Arc::new(ManualClock::default()); - let list = InMemoryRevocations::new(clock.clone()); - for (jti, hours) in [("later", 6), ("sooner", 2), ("middle", 4)] { - list.revoke(RevokedToken { - jti: jti.to_owned(), - expires_at: crate::store::deadline(clock.now(), SignedDuration::from_hours(hours)), - }) - .await - .expect("revocation recorded"); - } - - let published = list.published().await.expect("reads back"); - let order: Vec<&str> = published - .revoked - .iter() - .map(|token| token.jti.as_str()) - .collect(); - assert_eq!(order, ["sooner", "middle", "later"]); -} - #[test] fn a_listed_token_is_refused() { let now = at(0); diff --git a/capsule-server/src/federation/capability.rs b/capsule-server/src/federation/capability.rs new file mode 100644 index 00000000..71e6dde7 --- /dev/null +++ b/capsule-server/src/federation/capability.rs @@ -0,0 +1,811 @@ +//! The federation capability token, and the codec that mints and reads it. +//! +//! # The format is the contract +//! +//! design/federation.md makes the claim set normative — it is what every federated peer parses +//! and what this server signs — so the shape here is that table verbatim and nothing more: +//! +//! ```text +//! { "iss": , "sub": , "aud": "urn:capsule:album:", +//! "scope": "read" | "read-derivative-only", +//! "iat": , "exp": , "nbf": , +//! "jti": , "min_protocol_version": } +//! ``` +//! +//! Three deviations from RFC 7519 defaults, each the design's and each enforced here rather +//! than left to a peer's discretion: +//! +//! - **`aud` names the album, never the recipient.** The recipient is `sub`. A verifier that +//! matched `aud` against itself would accept every capability for every album, so +//! `jsonwebtoken`'s audience check is off and [`CapabilityCodec::verify`] hands the album back +//! for the *route* to match against the album being pulled. +//! - **The three instants are RFC 3339 strings**, not numeric dates, so the library's own +//! `exp`/`nbf` checks — against the system clock, with sixty seconds of leeway — are off and +//! every temporal decision is made here against the injected [`Clock`]. The same rule +//! [`crate::auth::tokens`] applies to session tokens, for the same reason: a deadline a test +//! cannot walk over is a deadline nobody tests. +//! - **`exp` is never more than 24 hours after `iat`.** Minting clamps; verification refuses a +//! wider window even under a valid signature, because the published revocation list is +//! bounded *by* that ceiling and one long-lived token would quietly break the bound. +//! +//! # One key, two token types +//! +//! The codec signs with the **same** Ed25519 key `SessionTokens` does — the operational key +//! `server-info` publishes — and the two token types cannot be confused with each other: a +//! session token carries `iss = "capsule-api"` and a required `kind`, a capability carries +//! `iss = ` and no `kind`, so each verifier finds the other's tokens unreadable by +//! construction. +//! +//! # Whole seconds, deliberately +//! +//! Every instant a capability carries is truncated to the second at mint. That is what lets a +//! grant be **re-signed byte-for-byte** from its stored record ([`CapabilityCodec::sign`]): +//! the refresh operation is idempotent on `(peer, jti)` and must answer a replay with the same +//! successor token, and a store that keeps microseconds cannot reproduce a nanosecond string. +//! Ed25519 signatures are deterministic, so the same claims sign to the same bytes. + +use std::fmt; +use std::sync::Arc; + +use jiff::{SignedDuration, Timestamp}; +use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, Validation}; +use serde::{Deserialize, Serialize}; + +use super::PeerId; +use crate::auth::tokens::SigningKeyError; +use crate::discovery::revocation::MAX_TOKEN_TTL; +use crate::store::{AlbumId, BlobRole, Clock}; + +/// The URN prefix an album-scoped `aud` claim carries. +pub const ALBUM_URN_PREFIX: &str = "urn:capsule:album:"; + +/// The `aud` claim for `album`. +#[must_use] +pub fn album_urn(album: &AlbumId) -> String { + format!("{ALBUM_URN_PREFIX}{}", album.as_str()) +} + +/// The album an `aud` claim names, or `None` for a claim that is not an album URN. +/// +/// The suffix must be a UUID, because an album id is one: a URN over any other text is not a +/// claim this server ever minted. +#[must_use] +pub fn album_from_urn(aud: &str) -> Option { + let id = aud.strip_prefix(ALBUM_URN_PREFIX)?; + uuid::Uuid::parse_str(id).ok().map(|_| AlbumId::new(id)) +} + +/// What a capability grants over an album's blobs. +/// +/// Enforced structurally against each blob's server-visible **role**, which is on its index row +/// and named by its signed envelope: a derivative-only capability is refused an `original` at +/// `GET /v1/blob/{hash}` whatever the peer claims to be fetching. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum Scope { + /// Everything a member reads: originals, derivatives, metadata, provenance. + Read, + /// Thumbnails and previews only — never originals. + ReadDerivativeOnly, +} + +impl Scope { + /// The stable token this scope travels under, on the wire and in a column. + pub fn as_str(self) -> &'static str { + match self { + Self::Read => "read", + Self::ReadDerivativeOnly => "read-derivative-only", + } + } + + /// The scope a stored token names, or `None` for a token no version of this server wrote. + pub fn from_token(token: &str) -> Option { + match token { + "read" => Some(Self::Read), + "read-derivative-only" => Some(Self::ReadDerivativeOnly), + _ => None, + } + } + + /// Whether a blob of `role` may be fetched under this scope. + /// + /// A backup is refused under both: a peer pulls an album's assets, and a backup copy is the + /// owner's own durability artefact rather than part of what was shared. + pub fn permits(self, role: BlobRole) -> bool { + match role { + BlobRole::Backup => false, + BlobRole::Original => matches!(self, Self::Read), + BlobRole::Derivative | BlobRole::Metadata | BlobRole::Provenance => true, + } + } +} + +impl fmt::Display for Scope { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +/// The claims a capability carries. Serialized as the JWT payload, verbatim from the design. +/// +/// `deny_unknown_fields` because the set is closed: a claim the contract does not name is a +/// token this server did not mint, whatever its signature says. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct Claims { + iss: String, + sub: String, + aud: String, + scope: Scope, + iat: String, + exp: String, + nbf: String, + jti: String, + min_protocol_version: String, +} + +/// What a capability turned out to grant, once it verified. +/// +/// Carries no raw token: everything downstream needs is here, and handing on the credential +/// itself is how one ends up in a log. `aud` has been parsed into the album it names, so a +/// route matches an [`AlbumId`] against an [`AlbumId`] rather than re-parsing a URN. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CapabilityGrant { + /// The peer server the grant was issued to (`sub`). + pub peer: PeerId, + /// The album it scopes to (`aud`). + pub album: AlbumId, + /// What it permits. + pub scope: Scope, + /// The revocation key (`jti`). + pub jti: String, + /// When it was issued; also its `nbf`. + pub issued_at: Timestamp, + /// When it stops being honoured. + pub expires_at: Timestamp, + /// The album's pinned protocol date, which the peer selects its parser from. + pub min_protocol_version: String, +} + +/// Why a presented capability was not honoured. +/// +/// Deliberately carries no fragment of the token. The variants exist so the unit suite can +/// assert *which* mutation was refused; on the wire every one of them but [`Self::Expired`] +/// collapses into the framework's uncoded `401`, as the session scheme's do. +#[derive(Debug, thiserror::Error, PartialEq, Eq)] +pub enum CapabilityError { + /// The token did not verify: a bad signature, a malformed payload, or a missing claim. + #[error("the capability could not be read")] + Unreadable, + /// The token verified and was issued by some other server. + #[error("the capability was issued by another server")] + WrongIssuer, + /// A claim is present and is not the shape the contract fixes. + #[error("the capability's {claim} claim is malformed")] + Malformed { + /// The claim that did not parse. + claim: &'static str, + }, + /// `exp` is more than the ceiling after `iat`. + #[error("the capability's lifetime exceeds the {MAX_TOKEN_TTL} ceiling")] + BeyondTtlCeiling, + /// `nbf` is in the future on this server's clock. + #[error("the capability is not valid yet")] + NotYetValid, + /// `exp` has passed. + #[error("the capability has expired")] + Expired, +} + +/// The claims could not be signed. The server's fault, never the caller's. +#[derive(Debug, thiserror::Error)] +#[error("the capability could not be signed: {detail}")] +pub struct MintError { + /// The signer's own description of the failure. + pub detail: String, +} + +/// What a mint asks for. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MintRequest { + /// The peer server the grant is for. + pub peer: PeerId, + /// The album it scopes to. + pub album: AlbumId, + /// What it permits. + pub scope: Scope, + /// The album's pinned protocol date. + pub min_protocol_version: String, + /// The requested lifetime. Clamped into `1s ..= MAX_TOKEN_TTL`, never refused: a + /// capability that expired before it was issued would be one the codec signs and cannot + /// read. + pub ttl: SignedDuration, +} + +/// A freshly minted capability. +/// +/// `Debug` is hand-written: the token is a bearer credential and a derived impl would publish +/// it to any `tracing` field that formatted the struct. +#[derive(Clone, PartialEq, Eq)] +pub struct Minted { + /// The signed token, to hand to the peer. + pub token: String, + /// What it grants, for the issuer's own record. + pub grant: CapabilityGrant, +} + +impl fmt::Debug for Minted { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("Minted") + .field("token", &"") + .field("grant", &self.grant) + .finish() + } +} + +/// Mints and reads capabilities under this server's operational key. +/// +/// `Debug` is hand-written and prints no key material. +pub struct CapabilityCodec { + signing: EncodingKey, + verifying: DecodingKey, + public_key: Vec, + validation: Validation, + server_id: String, + clock: Arc, +} + +impl fmt::Debug for CapabilityCodec { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("CapabilityCodec") + .field("server_id", &self.server_id) + .field("keys", &"") + .finish_non_exhaustive() + } +} + +impl CapabilityCodec { + /// A codec over the operator's PKCS#8 Ed25519 key, issuing as `server_id`. + /// + /// The **same** bytes `SessionTokens::from_pkcs8` takes, so the public half this derives is + /// the one `server-info` publishes and the one a peer verifies against — `boot` asserts the + /// two agree. `from_pkcs8_maybe_unchecked` for the reason the session signer uses it: a v1 + /// PKCS#8 document, which is what `openssl genpkey` writes, lacks the public half that is + /// being derived here anyway. + /// + /// # Errors + /// + /// Returns [`SigningKeyError`] if `pkcs8_der` is not a readable Ed25519 private key. + pub fn from_pkcs8( + pkcs8_der: &[u8], + server_id: impl Into, + clock: Arc, + ) -> Result { + use ring::signature::KeyPair as _; + + let pair = ring::signature::Ed25519KeyPair::from_pkcs8_maybe_unchecked(pkcs8_der).map_err( + |error| SigningKeyError { + detail: error.to_string(), + }, + )?; + let public_key = pair.public_key().as_ref().to_vec(); + + // Everything temporal is decided here against `clock`, and `aud` is matched by the + // route against the album: the library checks the signature and the algorithm and that + // the three identity claims are present, and nothing else. + let mut validation = Validation::new(Algorithm::EdDSA); + validation.set_required_spec_claims(&["iss", "sub", "aud"]); + validation.validate_exp = false; + validation.validate_nbf = false; + validation.validate_aud = false; + + Ok(Self { + signing: EncodingKey::from_ed_der(pkcs8_der), + verifying: DecodingKey::from_ed_der(&public_key), + public_key, + validation, + server_id: server_id.into(), + clock, + }) + } + + /// The issuer every capability from this codec carries. + pub fn server_id(&self) -> &str { + &self.server_id + } + + /// The raw Ed25519 public key capabilities verify under. Thirty-two bytes, no encoding. + pub fn public_key(&self) -> &[u8] { + &self.public_key + } + + /// Mint a capability for `request`, at the clock's now. + /// + /// `iat = nbf = now`, `exp = now + min(ttl, ceiling)`, a fresh UUIDv7 `jti`, all instants + /// at whole seconds (see the module docs). + /// + /// # Errors + /// + /// Returns [`MintError`] if the claims cannot be signed. + pub fn mint(&self, request: &MintRequest) -> Result { + let now = whole_seconds(self.clock.now()); + let ttl = request + .ttl + .clamp(SignedDuration::from_secs(1), MAX_TOKEN_TTL); + let grant = CapabilityGrant { + peer: request.peer.clone(), + album: request.album.clone(), + scope: request.scope, + jti: uuid::Uuid::now_v7().to_string(), + issued_at: now, + expires_at: whole_seconds(crate::store::deadline(now, ttl)), + min_protocol_version: request.min_protocol_version.clone(), + }; + let token = self.sign(&grant)?; + tracing::info!( + peer = %grant.peer, + album = %grant.album, + scope = %grant.scope, + jti = %grant.jti, + expires_at = %grant.expires_at, + "minted a federation capability" + ); + Ok(Minted { token, grant }) + } + + /// Sign `grant` exactly as it was first minted. + /// + /// What answers a replayed refresh with the same successor: the grant is rebuilt from its + /// stored record and re-signed, and because every instant is at whole seconds and Ed25519 + /// is deterministic, the bytes are the bytes the peer already holds. + /// + /// # Errors + /// + /// Returns [`MintError`] if the claims cannot be signed. + pub fn sign(&self, grant: &CapabilityGrant) -> Result { + let claims = Claims { + iss: self.server_id.clone(), + sub: grant.peer.as_str().to_owned(), + aud: album_urn(&grant.album), + scope: grant.scope, + iat: grant.issued_at.to_string(), + exp: grant.expires_at.to_string(), + nbf: grant.issued_at.to_string(), + jti: grant.jti.clone(), + min_protocol_version: grant.min_protocol_version.clone(), + }; + jsonwebtoken::encode(&Header::new(Algorithm::EdDSA), &claims, &self.signing).map_err( + |error| MintError { + detail: error.to_string(), + }, + ) + } + + /// Read a presented capability. + /// + /// Signature and issuer first, then the shape of every claim, then the window against the + /// ceiling, then the clock. The order reports the most specific true reason without ever + /// computing with a claim that has not yet been checked. + /// + /// # Errors + /// + /// Returns [`CapabilityError`] for every way a token can fail; none carries any of it. + pub fn verify(&self, presented: &str) -> Result { + let claims = jsonwebtoken::decode::(presented, &self.verifying, &self.validation) + .map_err(|error| { + // The *kind* names which check failed and never any part of the credential. + tracing::debug!(reason = ?error.kind(), "a presented capability did not verify"); + CapabilityError::Unreadable + })? + .claims; + + if claims.iss != self.server_id { + tracing::debug!("a presented capability names another issuer"); + return Err(CapabilityError::WrongIssuer); + } + if claims.sub.is_empty() { + return Err(CapabilityError::Malformed { claim: "sub" }); + } + // A UUIDv7, as the table says and as this server mints: any other `jti` is a token + // this server did not issue, whatever key it verifies under. + if !uuid::Uuid::parse_str(&claims.jti).is_ok_and(|id| id.get_version_num() == 7) { + return Err(CapabilityError::Malformed { claim: "jti" }); + } + if claims + .min_protocol_version + .parse::() + .is_err() + { + return Err(CapabilityError::Malformed { + claim: "min_protocol_version", + }); + } + let album = + album_from_urn(&claims.aud).ok_or(CapabilityError::Malformed { claim: "aud" })?; + let issued_at = instant(&claims.iat, "iat")?; + let expires_at = instant(&claims.exp, "exp")?; + let not_before = instant(&claims.nbf, "nbf")?; + if expires_at <= issued_at { + return Err(CapabilityError::Malformed { claim: "exp" }); + } + if expires_at.duration_since(issued_at) > MAX_TOKEN_TTL { + tracing::debug!(jti = %claims.jti, "a presented capability outlives the ceiling"); + return Err(CapabilityError::BeyondTtlCeiling); + } + + let now = self.clock.now(); + if now < not_before { + tracing::debug!(jti = %claims.jti, "a presented capability is not valid yet"); + return Err(CapabilityError::NotYetValid); + } + if expires_at <= now { + tracing::debug!(jti = %claims.jti, "a presented capability has expired"); + return Err(CapabilityError::Expired); + } + + Ok(CapabilityGrant { + peer: PeerId::new(claims.sub), + album, + scope: claims.scope, + jti: claims.jti, + issued_at, + expires_at, + min_protocol_version: claims.min_protocol_version, + }) + } +} + +/// `at` with its sub-second part dropped. +fn whole_seconds(at: Timestamp) -> Timestamp { + Timestamp::from_second(at.as_second()).unwrap_or(at) +} + +/// An RFC 3339 claim as an instant. +fn instant(text: &str, claim: &'static str) -> Result { + text.parse::() + .map_err(|_| CapabilityError::Malformed { claim }) +} + +#[cfg(test)] +mod tests { + use base64::Engine as _; + use base64::engine::general_purpose::URL_SAFE_NO_PAD; + use serde_json::{Value, json}; + + use super::*; + use crate::store::memory::ManualClock; + + const SERVER: &str = "home.test"; + + fn der() -> Vec { + ring::signature::Ed25519KeyPair::generate_pkcs8(&ring::rand::SystemRandom::new()) + .expect("the platform generates a key") + .as_ref() + .to_vec() + } + + fn codec(clock: Arc) -> CapabilityCodec { + CapabilityCodec::from_pkcs8(&der(), SERVER, clock).expect("a fresh key parses") + } + + fn request() -> MintRequest { + MintRequest { + peer: PeerId::new("other.test"), + album: AlbumId::new("01937b7c-0000-7000-8000-00000000a1b0"), + scope: Scope::ReadDerivativeOnly, + min_protocol_version: "2026-06-01".to_owned(), + ttl: SignedDuration::from_hours(6), + } + } + + /// The payload of `token`, as JSON. + fn payload(token: &str) -> Value { + let segment = token.split('.').nth(1).expect("a JWT has three segments"); + serde_json::from_slice(&URL_SAFE_NO_PAD.decode(segment).expect("base64url")) + .expect("the payload is JSON") + } + + /// `token` with its payload replaced by `edit(payload)`, re-signed under `key`. + fn resigned(token: &str, key: &[u8], edit: impl FnOnce(&mut Value)) -> String { + let mut claims = payload(token); + edit(&mut claims); + jsonwebtoken::encode( + &Header::new(Algorithm::EdDSA), + &claims, + &EncodingKey::from_ed_der(key), + ) + .expect("the edited claims sign") + } + + #[test] + fn a_minted_capability_verifies_and_carries_exactly_the_contracts_claims() { + let clock = Arc::new(ManualClock::default()); + let codec = codec(clock); + let minted = codec.mint(&request()).expect("it mints"); + + let claims = payload(&minted.token); + let keys: Vec<&str> = claims + .as_object() + .expect("an object") + .keys() + .map(String::as_str) + .collect(); + assert_eq!( + keys, + [ + "aud", + "exp", + "iat", + "iss", + "jti", + "min_protocol_version", + "nbf", + "scope", + "sub" + ], + "the claim set is the design's table and nothing else" + ); + assert_eq!(claims["iss"], SERVER); + assert_eq!(claims["sub"], "other.test"); + assert_eq!( + claims["aud"], + "urn:capsule:album:01937b7c-0000-7000-8000-00000000a1b0" + ); + assert_eq!(claims["scope"], "read-derivative-only"); + assert_eq!(claims["iat"], "1970-01-01T00:00:00Z"); + assert_eq!(claims["nbf"], "1970-01-01T00:00:00Z"); + assert_eq!(claims["exp"], "1970-01-01T06:00:00Z"); + assert_eq!( + uuid::Uuid::parse_str(claims["jti"].as_str().expect("a string")) + .expect("a uuid") + .get_version_num(), + 7 + ); + + let grant = codec.verify(&minted.token).expect("it verifies"); + assert_eq!(grant, minted.grant); + } + + #[test] + fn a_grant_re_signs_to_the_same_bytes() { + // What refresh idempotency rests on: the record can reproduce the token. + let codec = codec(Arc::new(ManualClock::default())); + let minted = codec.mint(&request()).expect("it mints"); + assert_eq!(codec.sign(&minted.grant).expect("it signs"), minted.token); + } + + #[test] + fn the_lifetime_is_clamped_to_the_ceiling_and_the_instants_are_whole_seconds() { + let clock = Arc::new(ManualClock::new( + Timestamp::from_nanosecond(1_700_000_000_123_456_789).expect("an instant"), + )); + let codec = codec(clock); + let minted = codec + .mint(&MintRequest { + ttl: SignedDuration::from_hours(48), + ..request() + }) + .expect("it mints"); + assert_eq!( + minted.grant.issued_at, + Timestamp::from_second(1_700_000_000).expect("an instant") + ); + assert_eq!( + minted + .grant + .expires_at + .duration_since(minted.grant.issued_at), + MAX_TOKEN_TTL + ); + assert!(codec.verify(&minted.token).is_ok()); + } + + #[test] + fn every_mutation_of_a_claim_is_refused_with_its_reason() { + // The federation doc's own unit bullet: mutate each claim and assert the reason. + let clock = Arc::new(ManualClock::default()); + let key = der(); + let codec = CapabilityCodec::from_pkcs8(&key, SERVER, clock.clone()).expect("parses"); + let minted = codec.mint(&request()).expect("it mints"); + let token = &minted.token; + + // A lifetime that is not positive is clamped up rather than signed unreadable. + let instant = codec + .mint(&MintRequest { + ttl: SignedDuration::from_secs(-5), + ..request() + }) + .expect("it mints"); + assert_eq!( + instant + .grant + .expires_at + .duration_since(instant.grant.issued_at), + SignedDuration::from_secs(1) + ); + assert!(codec.verify(&instant.token).is_ok()); + + // The signature: any other key, or a flipped payload byte under the right key. + let forged = resigned(token, &der(), |_| {}); + assert_eq!(codec.verify(&forged), Err(CapabilityError::Unreadable)); + let mut tampered = token.clone(); + let payload_start = tampered.find('.').expect("a dot") + 1; + let byte = tampered.as_bytes()[payload_start]; + tampered.replace_range( + payload_start..=payload_start, + if byte == b'A' { "B" } else { "A" }, + ); + assert_eq!(codec.verify(&tampered), Err(CapabilityError::Unreadable)); + + // Each claim, under the real key. + type Edit = Box; + let cases: [(&str, Edit, CapabilityError); 16] = [ + ( + "iss", + Box::new(|c| c["iss"] = json!("elsewhere.test")), + CapabilityError::WrongIssuer, + ), + ( + "sub", + Box::new(|c| c["sub"] = json!("")), + CapabilityError::Malformed { claim: "sub" }, + ), + ( + "aud", + Box::new(|c| c["aud"] = json!("urn:capsule:user:someone")), + CapabilityError::Malformed { claim: "aud" }, + ), + ( + "scope", + Box::new(|c| c["scope"] = json!("write")), + CapabilityError::Unreadable, + ), + ( + "iat", + Box::new(|c| c["iat"] = json!(0)), + CapabilityError::Unreadable, + ), + ( + "exp", + Box::new(|c| c["exp"] = json!("tomorrow")), + CapabilityError::Malformed { claim: "exp" }, + ), + ( + "exp before iat", + Box::new(|c| c["exp"] = json!("1969-12-31T23:00:00Z")), + CapabilityError::Malformed { claim: "exp" }, + ), + ( + "exp beyond the ceiling", + Box::new(|c| c["exp"] = json!("1970-01-02T00:00:01Z")), + CapabilityError::BeyondTtlCeiling, + ), + ( + "nbf", + Box::new(|c| c["nbf"] = json!("1970-01-01T01:00:00Z")), + CapabilityError::NotYetValid, + ), + ( + "jti", + Box::new(|c| c["jti"] = json!("")), + CapabilityError::Malformed { claim: "jti" }, + ), + ( + "jti that is not a UUIDv7", + Box::new(|c| c["jti"] = json!("2b6ed3a6-4c7e-4f3a-9d3c-1f1f1f1f1f1f")), + CapabilityError::Malformed { claim: "jti" }, + ), + ( + "aud whose suffix is not an album id", + Box::new(|c| c["aud"] = json!("urn:capsule:album:not-an-id")), + CapabilityError::Malformed { claim: "aud" }, + ), + ( + "min_protocol_version", + Box::new(|c| c["min_protocol_version"] = json!("soon")), + CapabilityError::Malformed { + claim: "min_protocol_version", + }, + ), + ( + "an extra claim", + Box::new(|c| c["kind"] = json!("access")), + CapabilityError::Unreadable, + ), + ( + "a missing claim", + Box::new(|c| { + c.as_object_mut().expect("an object").remove("jti"); + }), + CapabilityError::Unreadable, + ), + ( + "a session token's shape", + Box::new(|c| { + c["iss"] = json!("capsule-api"); + c["kind"] = json!("access"); + }), + CapabilityError::Unreadable, + ), + ]; + for (claim, edit, expected) in cases { + let mutated = resigned(token, &key, edit); + assert_eq!( + codec.verify(&mutated), + Err(expected), + "mutating {claim} was not refused as expected" + ); + } + + // And expiry, on the clock rather than by editing a claim. + clock.advance(SignedDuration::from_hours(6)); + assert_eq!(codec.verify(token), Err(CapabilityError::Expired)); + } + + #[test] + fn a_session_token_is_unreadable_to_the_capability_codec_and_vice_versa() { + // The same key signs both, and the two verifiers still cannot be confused: a session + // token carries `iss = capsule-api` and a `kind`, a capability neither. + let clock = Arc::new(ManualClock::default()); + let key = der(); + let codec = CapabilityCodec::from_pkcs8(&key, SERVER, clock.clone()).expect("parses"); + let sessions = crate::auth::SessionTokens::from_pkcs8(&key, clock).expect("parses"); + assert_eq!(codec.public_key(), sessions.public_key()); + + let issued = sessions + .issue( + &crate::store::UserId::new("user"), + &crate::store::SessionId::new("session"), + SignedDuration::from_hours(1), + ) + .expect("it issues"); + assert_eq!( + codec.verify(&issued.access_token), + Err(CapabilityError::Unreadable), + "a session token has no aud, which the capability codec requires" + ); + + let minted = codec.mint(&request()).expect("it mints"); + assert!(matches!( + sessions.verify(&minted.token, crate::auth::TokenKind::Access), + Err(crate::auth::TokenError::Unreadable) + )); + } + + #[test] + fn scope_is_decided_by_the_blobs_role() { + for role in [ + BlobRole::Derivative, + BlobRole::Metadata, + BlobRole::Provenance, + ] { + assert!(Scope::Read.permits(role)); + assert!(Scope::ReadDerivativeOnly.permits(role)); + } + assert!(Scope::Read.permits(BlobRole::Original)); + assert!(!Scope::ReadDerivativeOnly.permits(BlobRole::Original)); + assert!(!Scope::Read.permits(BlobRole::Backup)); + assert!(!Scope::ReadDerivativeOnly.permits(BlobRole::Backup)); + for scope in [Scope::Read, Scope::ReadDerivativeOnly] { + assert_eq!(Scope::from_token(scope.as_str()), Some(scope)); + } + assert_eq!(Scope::from_token("write"), None); + } + + #[test] + fn the_album_urn_round_trips_and_nothing_else_parses() { + let album = AlbumId::new("01937b7c-0000-7000-8000-00000000a1b0"); + assert_eq!(album_from_urn(&album_urn(&album)), Some(album)); + assert_eq!(album_from_urn("urn:capsule:album:"), None); + assert_eq!(album_from_urn("home.test"), None); + } + + #[test] + fn nothing_prints_a_token_or_a_key() { + let codec = codec(Arc::new(ManualClock::default())); + let minted = codec.mint(&request()).expect("it mints"); + let rendered = format!("{minted:?} {codec:?}"); + assert!(!rendered.contains(&minted.token), "{rendered}"); + assert!(rendered.contains(""), "{rendered}"); + } +} diff --git a/capsule-server/src/federation/conformance.rs b/capsule-server/src/federation/conformance.rs new file mode 100644 index 00000000..47bdaaca --- /dev/null +++ b/capsule-server/src/federation/conformance.rs @@ -0,0 +1,600 @@ +//! The one suite every [`CapabilityStore`] and [`PeerStore`] adapter must pass. +//! +//! # The rules the suite exists to protect +//! +//! - **The store is the revocation list.** Revoking an issued capability publishes its `jti`; +//! revoking a `jti` nothing backs still publishes it; and what is published is pruned past +//! the token's own expiry and bounded by the TTL ceiling — the rules the standalone list +//! carried before this store replaced it. +//! - **Refresh is one operation and is idempotent.** The successor is recorded, the +//! predecessor is linked and revoked, and a replay answers with the same successor. +//! - **A refusal changes nothing.** `AlreadyRevoked`, `AlreadyRefreshed`, `AlreadyBlocked` +//! leave every row as it was. +//! +//! # Reusing a harness +//! +//! Every case scopes its own identifiers, so cases may share one store and [`run_all`] does. +//! The clock is the harness's own [`ManualClock`], because pruning is decided on the adapter's +//! clock rather than on an argument. + +use jiff::SignedDuration; + +use super::PeerId; +use super::capability::Scope; +use super::peers::{BlockOutcome, PeerStore, UnblockOutcome}; +use super::store::{ + CapabilityFilter, CapabilityRecord, CapabilityStore, RefreshOutcome, RevokeOutcome, +}; +use crate::discovery::revocation::{RevokeError, RevokedToken}; +use crate::store::memory::ManualClock; +use crate::store::{AlbumId, Clock as _, StoreError, UserId}; + +/// The stores under test. +pub trait Harness: Send + Sync { + /// The capability store under test. + fn capabilities(&self) -> &dyn CapabilityStore; + /// The peer store under test. + fn peers(&self) -> &dyn PeerStore; + /// The clock both adapters read. + fn clock(&self) -> &ManualClock; +} + +/// Unwrap a store result, failing with the operation that was expected to work. +#[track_caller] +fn ok(result: Result, doing: &str) -> T { + match result { + Ok(value) => value, + Err(error) => panic!("a conforming federation store must succeed at {doing}: {error}"), + } +} + +/// A capability for `case`, minted at the clock's now and good for `hours`. +fn record(h: &dyn Harness, case: &str, jti: &str, hours: i64) -> CapabilityRecord { + let now = h.clock().now(); + CapabilityRecord { + jti: format!("{case}-{jti}"), + album_id: AlbumId::new(format!("{case}-album")), + peer_id: PeerId::new(format!("{case}.peer.test")), + member: UserId::new(format!("{case}-member")), + scope: Scope::Read, + granted_epoch: 3, + min_protocol_version: "2026-06-01".to_owned(), + issued_at: now, + expires_at: crate::store::deadline(now, SignedDuration::from_hours(hours)), + revoked_at: None, + refreshed_to: None, + } +} + +async fn issue(h: &dyn Harness, record: CapabilityRecord) { + ok(h.capabilities().issue(record).await, "record a capability"); +} + +async fn find(h: &dyn Harness, jti: &str) -> Option { + ok(h.capabilities().find(jti).await, "find a capability") +} + +async fn published(h: &dyn Harness) -> Vec { + ok(h.capabilities().published().await, "read the list") + .revoked + .into_iter() + .map(|token| token.jti) + .collect() +} + +// =========================================================================================== +// Capabilities +// =========================================================================================== + +/// An issued capability reads back whole, and an unknown `jti` is `None`. +pub async fn an_issued_capability_reads_back_and_an_unknown_jti_is_none(h: &dyn Harness) { + let case = "readback"; + let record = record(h, case, "one", 6); + issue(h, record.clone()).await; + assert_eq!(find(h, &record.jti).await, Some(record.clone())); + assert!(record.is_live(h.clock().now())); + assert_eq!(find(h, "readback-never").await, None); +} + +/// A second record under one `jti` is refused as a rejection, and the first stands. +pub async fn a_duplicate_jti_is_rejected_and_the_first_record_stands(h: &dyn Harness) { + let case = "duplicate"; + let first = record(h, case, "one", 6); + issue(h, first.clone()).await; + let error = h + .capabilities() + .issue(CapabilityRecord { + scope: Scope::ReadDerivativeOnly, + ..first.clone() + }) + .await + .expect_err("a jti is minted once"); + assert!(matches!(error, StoreError::Rejected { .. }), "{error:?}"); + assert_eq!(find(h, &first.jti).await, Some(first)); +} + +/// `live` answers by album and by peer, and leaves out the revoked and the expired. +pub async fn live_filters_by_album_and_peer_and_excludes_the_revoked_and_expired(h: &dyn Harness) { + let case = "live"; + let now = h.clock().now(); + let a = record(h, case, "a", 6); + let b = CapabilityRecord { + peer_id: PeerId::new("other-live.peer.test"), + ..record(h, case, "b", 6) + }; + let revoked = record(h, case, "revoked", 6); + let expiring = record(h, case, "expiring", 1); + for record in [&a, &b, &revoked, &expiring] { + issue(h, record.clone()).await; + } + assert_eq!( + h.capabilities() + .revoke_issued(&revoked.jti, now) + .await + .expect("revokes"), + RevokeOutcome::Revoked + ); + + let later = crate::store::deadline(now, SignedDuration::from_hours(2)); + let mut by_album: Vec = ok( + h.capabilities() + .live(&CapabilityFilter::Album(a.album_id.clone()), later) + .await, + "list by album", + ) + .into_iter() + .map(|record| record.jti) + .collect(); + by_album.sort(); + assert_eq!(by_album, vec![a.jti.clone(), b.jti.clone()]); + + let by_peer: Vec = ok( + h.capabilities() + .live(&CapabilityFilter::Peer(b.peer_id.clone()), later) + .await, + "list by peer", + ) + .into_iter() + .map(|record| record.jti) + .collect(); + assert_eq!(by_peer, vec![b.jti]); +} + +/// Revoking an issued capability sets `revoked_at`, publishes its `jti`, and is idempotent. +pub async fn revoking_an_issued_capability_publishes_it_once(h: &dyn Harness) { + let case = "revoke"; + let now = h.clock().now(); + let record = record(h, case, "one", 6); + issue(h, record.clone()).await; + + assert_eq!( + h.capabilities() + .revoke_issued(&record.jti, now) + .await + .expect("revokes"), + RevokeOutcome::Revoked + ); + let stored = find(h, &record.jti).await.expect("still recorded"); + assert_eq!(stored.revoked_at, Some(now)); + assert!(!stored.is_live(now)); + assert!(published(h).await.contains(&record.jti)); + + assert_eq!( + h.capabilities() + .revoke_issued(&record.jti, now) + .await + .expect("answers"), + RevokeOutcome::AlreadyRevoked + ); + assert_eq!( + find(h, &record.jti) + .await + .expect("still recorded") + .revoked_at, + Some(now), + "a retry does not move the instant" + ); + assert_eq!( + h.capabilities() + .revoke_issued("revoke-never", now) + .await + .expect("answers"), + RevokeOutcome::Unknown + ); + assert_eq!( + published(h) + .await + .iter() + .filter(|jti| *jti == &record.jti) + .count(), + 1, + "one entry however many times it is revoked" + ); +} + +/// A `jti` nothing backs is still published, and one past the ceiling is refused. +pub async fn a_foreign_jti_is_published_and_one_beyond_the_ceiling_is_refused(h: &dyn Harness) { + let now = h.clock().now(); + h.capabilities() + .revoke(RevokedToken { + jti: "foreign-one".to_owned(), + expires_at: crate::store::deadline(now, SignedDuration::from_hours(2)), + }) + .await + .expect("a foreign jti is a fact the list carries"); + assert!(published(h).await.contains(&"foreign-one".to_owned())); + assert_eq!( + find(h, "foreign-one").await, + None, + "no record is invented for it" + ); + + let error = h + .capabilities() + .revoke(RevokedToken { + jti: "foreign-beyond".to_owned(), + expires_at: crate::store::deadline(now, SignedDuration::from_hours(25)), + }) + .await + .expect_err("an entry past the ceiling is refused"); + assert!(matches!(error, RevokeError::Refused(_)), "{error:?}"); + assert!(!published(h).await.contains(&"foreign-beyond".to_owned())); +} + +/// Revoking a `jti` through the list also revokes the record behind it, and once only. +pub async fn the_list_and_the_record_are_one_fact(h: &dyn Harness) { + let case = "onefact"; + let now = h.clock().now(); + let record = record(h, case, "one", 6); + issue(h, record.clone()).await; + let entry = RevokedToken { + jti: record.jti.clone(), + expires_at: record.expires_at, + }; + h.capabilities() + .revoke(entry.clone()) + .await + .expect("first revocation"); + h.capabilities() + .revoke(entry) + .await + .expect("a retry is not a new fact"); + assert_eq!( + find(h, &record.jti).await.expect("recorded").revoked_at, + Some(now) + ); + assert_eq!( + published(h) + .await + .iter() + .filter(|jti| *jti == &record.jti) + .count(), + 1 + ); +} + +/// A list-side revocation of an issued `jti` is published under the record's own expiry. +/// +/// A shorter expiry from the caller would prune the entry while the token still verifies — +/// a peer's cached list would drop it and honour a revoked token until its real `exp`. +pub async fn a_list_side_revocation_keeps_the_records_expiry(h: &dyn Harness) { + let case = "keepexp"; + let now = h.clock().now(); + let record = record(h, case, "one", 6); + issue(h, record.clone()).await; + h.capabilities() + .revoke(RevokedToken { + jti: record.jti.clone(), + expires_at: crate::store::deadline(now, SignedDuration::from_mins(1)), + }) + .await + .expect("revokes"); + let entry = ok(h.capabilities().published().await, "read the list") + .revoked + .into_iter() + .find(|token| token.jti == record.jti) + .expect("published"); + assert_eq!(entry.expires_at, record.expires_at); + assert_eq!( + find(h, &record.jti).await.expect("recorded").revoked_at, + Some(now) + ); +} + +/// A record that would outlive the ceiling is refused by the store, at issue and at refresh. +pub async fn a_record_past_the_ceiling_is_refused(h: &dyn Harness) { + let case = "ceiling"; + let now = h.clock().now(); + let long = record(h, case, "long", 25); + let error = h + .capabilities() + .issue(long.clone()) + .await + .expect_err("the list is bounded by the ceiling, so the store holds it too"); + assert!(matches!(error, StoreError::Rejected { .. }), "{error:?}"); + assert_eq!(find(h, &long.jti).await, None); + + let old = record(h, case, "old", 6); + issue(h, old.clone()).await; + let error = h + .capabilities() + .refresh(&old.jti, record(h, case, "long-successor", 25), now) + .await + .expect_err("a successor is held to the same ceiling"); + assert!(matches!(error, StoreError::Rejected { .. }), "{error:?}"); + let old = find(h, &old.jti).await.expect("recorded"); + assert_eq!(old.refreshed_to, None, "a refusal changes nothing"); + assert_eq!(old.revoked_at, None); +} + +/// A successor for another peer, album or member is refused; the link cannot widen a grant. +pub async fn a_successor_must_carry_the_predecessors_peer_album_and_member(h: &dyn Harness) { + let case = "widen"; + let now = h.clock().now(); + let old = record(h, case, "old", 6); + issue(h, old.clone()).await; + for (name, successor) in [ + ( + "peer", + CapabilityRecord { + peer_id: PeerId::new("widen-other.peer.test"), + ..record(h, case, "peer", 6) + }, + ), + ( + "album", + CapabilityRecord { + album_id: AlbumId::new("widen-other-album"), + ..record(h, case, "album", 6) + }, + ), + ( + "member", + CapabilityRecord { + member: UserId::new("widen-other-member"), + ..record(h, case, "member", 6) + }, + ), + ] { + let error = h + .capabilities() + .refresh(&old.jti, successor.clone(), now) + .await + .expect_err("a successor naming another peer, album or member is a rejection"); + assert!( + matches!(error, StoreError::Rejected { .. }), + "{name}: {error:?}" + ); + assert_eq!( + find(h, &successor.jti).await, + None, + "{name}: a refusal records nothing" + ); + } + let old = find(h, &old.jti).await.expect("recorded"); + assert_eq!(old.refreshed_to, None); + assert_eq!(old.revoked_at, None); +} + +/// An entry leaves the list once the token it names has expired, and the list orders by expiry. +pub async fn the_published_list_prunes_expired_entries_and_orders_by_expiry(h: &dyn Harness) { + let case = "prune"; + let now = h.clock().now(); + for (jti, hours) in [("later", 6), ("sooner", 2), ("middle", 4)] { + let record = record(h, case, jti, hours); + issue(h, record.clone()).await; + h.capabilities() + .revoke_issued(&record.jti, now) + .await + .expect("revokes"); + } + let listed: Vec = published(h) + .await + .into_iter() + .filter(|jti| jti.starts_with("prune-")) + .collect(); + assert_eq!(listed, ["prune-sooner", "prune-middle", "prune-later"]); + + h.clock().advance(SignedDuration::from_hours(3)); + let list = ok(h.capabilities().published().await, "read the list"); + assert_eq!(list.generated_at, h.clock().now()); + let listed: Vec = list + .revoked + .into_iter() + .map(|token| token.jti) + .filter(|jti| jti.starts_with("prune-")) + .collect(); + assert_eq!( + listed, + ["prune-middle", "prune-later"], + "an expired token is refused whether or not it is listed, so its entry carries nothing" + ); +} + +/// A refresh issues the successor, links and revokes the predecessor, and replays. +pub async fn a_refresh_is_one_operation_and_a_replay_answers_the_same_successor(h: &dyn Harness) { + let case = "refresh"; + let now = h.clock().now(); + let old = record(h, case, "old", 6); + issue(h, old.clone()).await; + let new = record(h, case, "new", 6); + + let outcome = h + .capabilities() + .refresh(&old.jti, new.clone(), now) + .await + .expect("refreshes"); + assert_eq!(outcome, RefreshOutcome::Issued(new.clone())); + let stored_old = find(h, &old.jti).await.expect("recorded"); + assert_eq!(stored_old.refreshed_to, Some(new.jti.clone())); + assert_eq!(stored_old.revoked_at, Some(now)); + assert!(published(h).await.contains(&old.jti)); + assert_eq!(find(h, &new.jti).await, Some(new.clone())); + + // The replay: a different successor is offered and the first one is answered. + let another = record(h, case, "another", 6); + let replay = h + .capabilities() + .refresh(&old.jti, another.clone(), now) + .await + .expect("answers"); + assert_eq!(replay, RefreshOutcome::AlreadyRefreshed(new)); + assert_eq!( + find(h, &another.jti).await, + None, + "nothing was recorded for the replay" + ); +} + +/// A revoked predecessor cannot be refreshed, and an unknown one is unknown. +pub async fn a_revoked_or_unknown_predecessor_is_not_refreshed(h: &dyn Harness) { + let case = "norefresh"; + let now = h.clock().now(); + let revoked = record(h, case, "revoked", 6); + issue(h, revoked.clone()).await; + h.capabilities() + .revoke_issued(&revoked.jti, now) + .await + .expect("revokes"); + let successor = record(h, case, "successor", 6); + assert_eq!( + h.capabilities() + .refresh(&revoked.jti, successor.clone(), now) + .await + .expect("answers"), + RefreshOutcome::Revoked + ); + assert_eq!( + h.capabilities() + .refresh("norefresh-never", successor.clone(), now) + .await + .expect("answers"), + RefreshOutcome::Unknown + ); + assert_eq!( + find(h, &successor.jti).await, + None, + "a refusal records nothing" + ); +} + +// =========================================================================================== +// Peers +// =========================================================================================== + +/// An unknown peer is `None`; a pinned one reads back with its key and is not blocked. +pub async fn a_pinned_peer_reads_back_and_an_unknown_one_is_none(h: &dyn Harness) { + let peer = PeerId::new("pin.peer.test"); + let now = h.clock().now(); + assert_eq!(ok(h.peers().read(&peer).await, "read a peer"), None); + ok(h.peers().pin(&peer, [7; 32], now).await, "pin a peer"); + let record = ok(h.peers().read(&peer).await, "read a peer").expect("pinned"); + assert_eq!(record.server_id, peer); + assert_eq!(record.signing_key, Some([7; 32])); + assert_eq!(record.first_seen_at, now); + assert!(!record.is_blocked()); + assert_eq!(record.note, None); + + // A re-pin rotates the key and keeps the first-seen instant. + h.clock().advance(SignedDuration::from_hours(1)); + ok( + h.peers().pin(&peer, [8; 32], h.clock().now()).await, + "re-pin a peer", + ); + let record = ok(h.peers().read(&peer).await, "read a peer").expect("pinned"); + assert_eq!(record.signing_key, Some([8; 32])); + assert_eq!(record.first_seen_at, now); +} + +/// A block on a never-pinned peer creates a keyless row; a second block changes nothing. +pub async fn a_block_needs_no_key_and_is_idempotent(h: &dyn Harness) { + let peer = PeerId::new("block.peer.test"); + let now = h.clock().now(); + assert_eq!( + ok( + h.peers().block(&peer, now, Some("spam".to_owned())).await, + "block a peer" + ), + BlockOutcome::Blocked + ); + let record = ok(h.peers().read(&peer).await, "read a peer").expect("recorded"); + assert!(record.is_blocked()); + assert_eq!(record.blocked_at, Some(now)); + assert_eq!(record.signing_key, None); + assert_eq!(record.note.as_deref(), Some("spam")); + + let later = crate::store::deadline(now, SignedDuration::from_hours(1)); + assert_eq!( + ok( + h.peers() + .block(&peer, later, Some("again".to_owned())) + .await, + "block a peer again" + ), + BlockOutcome::AlreadyBlocked + ); + let record = ok(h.peers().read(&peer).await, "read a peer").expect("recorded"); + assert_eq!( + record.blocked_at, + Some(now), + "a retry does not move the instant" + ); + assert_eq!(record.note.as_deref(), Some("spam")); +} + +/// Pinning keeps a block, and unblocking keeps the key. +pub async fn a_pin_keeps_a_block_and_an_unblock_keeps_the_key(h: &dyn Harness) { + let peer = PeerId::new("keep.peer.test"); + let now = h.clock().now(); + ok(h.peers().block(&peer, now, None).await, "block a peer"); + ok( + h.peers().pin(&peer, [9; 32], now).await, + "pin a blocked peer", + ); + let record = ok(h.peers().read(&peer).await, "read a peer").expect("recorded"); + assert!( + record.is_blocked(), + "pinning a key is not an opinion about talking to its owner" + ); + assert_eq!(record.signing_key, Some([9; 32])); + + assert_eq!( + ok(h.peers().unblock(&peer).await, "unblock a peer"), + UnblockOutcome::Unblocked + ); + let record = ok(h.peers().read(&peer).await, "read a peer").expect("recorded"); + assert!(!record.is_blocked()); + assert_eq!(record.signing_key, Some([9; 32])); + assert_eq!( + ok(h.peers().unblock(&peer).await, "unblock a peer again"), + UnblockOutcome::NotBlocked + ); + assert_eq!( + ok( + h.peers() + .unblock(&PeerId::new("keep-never.peer.test")) + .await, + "unblock an unknown peer" + ), + UnblockOutcome::NotBlocked + ); +} + +/// Every case, against one harness. +pub async fn run_all(h: &dyn Harness) { + an_issued_capability_reads_back_and_an_unknown_jti_is_none(h).await; + a_duplicate_jti_is_rejected_and_the_first_record_stands(h).await; + live_filters_by_album_and_peer_and_excludes_the_revoked_and_expired(h).await; + revoking_an_issued_capability_publishes_it_once(h).await; + a_foreign_jti_is_published_and_one_beyond_the_ceiling_is_refused(h).await; + the_list_and_the_record_are_one_fact(h).await; + a_list_side_revocation_keeps_the_records_expiry(h).await; + a_record_past_the_ceiling_is_refused(h).await; + a_successor_must_carry_the_predecessors_peer_album_and_member(h).await; + the_published_list_prunes_expired_entries_and_orders_by_expiry(h).await; + a_refresh_is_one_operation_and_a_replay_answers_the_same_successor(h).await; + a_revoked_or_unknown_predecessor_is_not_refreshed(h).await; + a_pinned_peer_reads_back_and_an_unknown_one_is_none(h).await; + a_block_needs_no_key_and_is_idempotent(h).await; + a_pin_keeps_a_block_and_an_unblock_keeps_the_key(h).await; +} diff --git a/capsule-server/src/federation/memory.rs b/capsule-server/src/federation/memory.rs new file mode 100644 index 00000000..1aeabdb9 --- /dev/null +++ b/capsule-server/src/federation/memory.rs @@ -0,0 +1,402 @@ +//! The deterministic doubles: [`InMemoryCapabilities`] and [`InMemoryPeers`]. +//! +//! One mutex each, which is what makes every multi-step operation — revoke-and-publish, +//! refresh — one critical section, exactly as the Postgres adapter's transaction is. + +use std::collections::BTreeMap; +use std::sync::{Arc, Mutex}; + +use jiff::Timestamp; + +use super::PeerId; +use super::peers::{BlockOutcome, PeerRecord, PeerStore, UnblockOutcome}; +use super::store::{ + CapabilityFilter, CapabilityRecord, CapabilityStore, RefreshOutcome, RevokeOutcome, +}; +use crate::discovery::revocation::{ + MAX_TOKEN_TTL, PublishedRevocations, RevocationError, RevocationList, RevokeFuture, + RevokedToken, +}; +use crate::store::{Clock, StoreError, StoreFuture}; + +/// Take the lock, recovering from a poisoned mutex. +fn lock(mutex: &Mutex) -> std::sync::MutexGuard<'_, T> { + mutex + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) +} + +/// The deterministic capability store, and the revocation list it publishes. +#[derive(Debug)] +pub struct InMemoryCapabilities { + inner: Mutex, + clock: Arc, +} + +#[derive(Debug, Default)] +struct Inner { + /// Every capability issued here, by `jti`. + records: BTreeMap, + /// Every published revocation, by `jti`, with the token's own expiry for pruning. + published: BTreeMap, +} + +impl Inner { + /// Revoke `jti` at `at` if a record backs it, and publish it either way. + /// + /// The one place both halves happen, so no path can do one without the other. The expiry + /// the entry is published under is the **record's** when there is one — a caller's shorter + /// `expires_at` would prune the entry while the token still verifies, which is a peer + /// honouring a revoked token — and an entry already published is never shortened. + fn revoke(&mut self, jti: &str, expires_at: Timestamp, at: Timestamp) { + let mut expires_at = expires_at; + if let Some(record) = self.records.get_mut(jti) { + if record.revoked_at.is_none() { + record.revoked_at = Some(at); + } + expires_at = record.expires_at; + } + let entry = self.published.entry(jti.to_owned()).or_insert(expires_at); + *entry = (*entry).max(expires_at); + } + + /// Refuse a record whose lifetime the published list could not stay bounded under. + fn admissible(record: &CapabilityRecord) -> Result<(), StoreError> { + if record.expires_at.duration_since(record.issued_at) > MAX_TOKEN_TTL { + return Err(StoreError::Rejected { + store: "capabilities", + detail: format!( + "capability {} would live past the {MAX_TOKEN_TTL} ceiling", + record.jti + ), + }); + } + Ok(()) + } +} + +impl InMemoryCapabilities { + /// An empty store reading `clock` for pruning and for `generated_at`. + pub fn new(clock: Arc) -> Self { + Self { + inner: Mutex::new(Inner::default()), + clock, + } + } +} + +impl RevocationList for InMemoryCapabilities { + fn revoke(&self, token: RevokedToken) -> RevokeFuture<'_> { + Box::pin(async move { + let now = self.clock.now(); + let ceiling = crate::store::deadline(now, MAX_TOKEN_TTL); + if token.expires_at > ceiling { + tracing::warn!( + jti = %token.jti, + expires_at = %token.expires_at, + "a revocation was refused: its expiry is beyond the capability TTL ceiling" + ); + return Err(RevocationError::BeyondTtlCeiling { + expires_at: token.expires_at, + ceiling: MAX_TOKEN_TTL, + } + .into()); + } + let mut inner = lock(&self.inner); + inner.revoke(&token.jti, token.expires_at, now); + tracing::info!( + jti = %token.jti, + expires_at = %token.expires_at, + published = inner.published.len(), + "a federation capability token was revoked" + ); + Ok(()) + }) + } + + fn published(&self) -> StoreFuture<'_, PublishedRevocations> { + Box::pin(async move { + let now = self.clock.now(); + let mut inner = lock(&self.inner); + // Pruned on read *and* retained pruned, so a list nobody fetches does not grow + // forever holding entries that already mean nothing. + inner.published.retain(|_, expires_at| *expires_at > now); + let mut revoked: Vec = inner + .published + .iter() + .map(|(jti, expires_at)| RevokedToken { + jti: jti.clone(), + expires_at: *expires_at, + }) + .collect(); + revoked.sort_by_key(|token| (token.expires_at, token.jti.clone())); + Ok(PublishedRevocations { + generated_at: now, + revoked, + }) + }) + } +} + +impl CapabilityStore for InMemoryCapabilities { + fn issue(&self, record: CapabilityRecord) -> StoreFuture<'_, ()> { + Box::pin(async move { + Inner::admissible(&record)?; + let mut inner = lock(&self.inner); + if inner.records.contains_key(&record.jti) { + return Err(StoreError::Rejected { + store: "capabilities", + detail: format!("a capability with jti {} is already recorded", record.jti), + }); + } + tracing::info!( + jti = %record.jti, + peer = %record.peer_id, + album = %record.album_id, + member = %record.member, + scope = %record.scope, + granted_epoch = record.granted_epoch, + expires_at = %record.expires_at, + "a federation capability was recorded" + ); + inner.records.insert(record.jti.clone(), record); + Ok(()) + }) + } + + fn find<'a>(&'a self, jti: &'a str) -> StoreFuture<'a, Option> { + Box::pin(async move { Ok(lock(&self.inner).records.get(jti).cloned()) }) + } + + fn live<'a>( + &'a self, + filter: &'a CapabilityFilter, + now: Timestamp, + ) -> StoreFuture<'a, Vec> { + Box::pin(async move { + Ok(lock(&self.inner) + .records + .values() + .filter(|record| record.is_live(now)) + .filter(|record| match filter { + CapabilityFilter::Album(album) => &record.album_id == album, + CapabilityFilter::Peer(peer) => &record.peer_id == peer, + }) + .cloned() + .collect()) + }) + } + + fn revoke_issued<'a>(&'a self, jti: &'a str, at: Timestamp) -> StoreFuture<'a, RevokeOutcome> { + Box::pin(async move { + let mut inner = lock(&self.inner); + let Some(record) = inner.records.get(jti) else { + return Ok(RevokeOutcome::Unknown); + }; + if record.revoked_at.is_some() { + return Ok(RevokeOutcome::AlreadyRevoked); + } + let expires_at = record.expires_at; + inner.revoke(jti, expires_at, at); + tracing::info!(%jti, published = inner.published.len(), "an issued capability was revoked"); + Ok(RevokeOutcome::Revoked) + }) + } + + fn refresh<'a>( + &'a self, + predecessor: &'a str, + successor: CapabilityRecord, + at: Timestamp, + ) -> StoreFuture<'a, RefreshOutcome> { + Box::pin(async move { + Inner::admissible(&successor)?; + let mut inner = lock(&self.inner); + let Some(old) = inner.records.get(predecessor) else { + return Ok(RefreshOutcome::Unknown); + }; + if successor.peer_id != old.peer_id + || successor.album_id != old.album_id + || successor.member != old.member + { + return Err(StoreError::Rejected { + store: "capabilities", + detail: format!( + "a successor of {predecessor} must carry its peer, album and member" + ), + }); + } + if let Some(next) = &old.refreshed_to { + let existing = + inner + .records + .get(next) + .cloned() + .ok_or_else(|| StoreError::Corrupt { + store: "capabilities", + record: "CapabilityRecord", + detail: format!( + "{predecessor} was refreshed to {next}, which is not recorded" + ), + })?; + return Ok(RefreshOutcome::AlreadyRefreshed(existing)); + } + if old.revoked_at.is_some() { + return Ok(RefreshOutcome::Revoked); + } + if inner.records.contains_key(&successor.jti) { + return Err(StoreError::Rejected { + store: "capabilities", + detail: format!( + "a capability with jti {} is already recorded", + successor.jti + ), + }); + } + let old_expires_at = old.expires_at; + inner.revoke(predecessor, old_expires_at, at); + if let Some(old) = inner.records.get_mut(predecessor) { + old.refreshed_to = Some(successor.jti.clone()); + } + tracing::info!( + predecessor = %predecessor, + successor = %successor.jti, + peer = %successor.peer_id, + "a federation capability was refreshed" + ); + inner + .records + .insert(successor.jti.clone(), successor.clone()); + Ok(RefreshOutcome::Issued(successor)) + }) + } +} + +/// The deterministic peer store. +#[derive(Debug, Default)] +pub struct InMemoryPeers { + peers: Mutex>, +} + +impl InMemoryPeers { + /// An empty store: no peer pinned, no peer blocked. + pub fn new() -> Self { + Self::default() + } +} + +impl PeerStore for InMemoryPeers { + fn pin<'a>( + &'a self, + peer: &'a PeerId, + signing_key: [u8; 32], + at: Timestamp, + ) -> StoreFuture<'a, ()> { + Box::pin(async move { + let mut peers = lock(&self.peers); + match peers.get_mut(peer) { + Some(record) => record.signing_key = Some(signing_key), + None => { + peers.insert( + peer.clone(), + PeerRecord { + server_id: peer.clone(), + signing_key: Some(signing_key), + first_seen_at: at, + blocked_at: None, + note: None, + }, + ); + } + } + tracing::info!(%peer, "a peer's signing key was pinned"); + Ok(()) + }) + } + + fn read<'a>(&'a self, peer: &'a PeerId) -> StoreFuture<'a, Option> { + Box::pin(async move { Ok(lock(&self.peers).get(peer).cloned()) }) + } + + fn block<'a>( + &'a self, + peer: &'a PeerId, + at: Timestamp, + note: Option, + ) -> StoreFuture<'a, BlockOutcome> { + Box::pin(async move { + let mut peers = lock(&self.peers); + let record = peers.entry(peer.clone()).or_insert_with(|| PeerRecord { + server_id: peer.clone(), + signing_key: None, + first_seen_at: at, + blocked_at: None, + note: None, + }); + if record.blocked_at.is_some() { + return Ok(BlockOutcome::AlreadyBlocked); + } + record.blocked_at = Some(at); + record.note = note; + tracing::warn!(%peer, "a peer server was blocked"); + Ok(BlockOutcome::Blocked) + }) + } + + fn unblock<'a>(&'a self, peer: &'a PeerId) -> StoreFuture<'a, UnblockOutcome> { + Box::pin(async move { + let mut peers = lock(&self.peers); + match peers.get_mut(peer) { + Some(record) if record.blocked_at.is_some() => { + record.blocked_at = None; + record.note = None; + tracing::info!(%peer, "a peer server was unblocked"); + Ok(UnblockOutcome::Unblocked) + } + _ => Ok(UnblockOutcome::NotBlocked), + } + }) + } +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use super::super::conformance::{self, Harness}; + use super::{InMemoryCapabilities, InMemoryPeers}; + use crate::federation::{CapabilityStore, PeerStore}; + use crate::store::memory::ManualClock; + + #[derive(Debug)] + struct MemoryHarness { + clock: Arc, + capabilities: InMemoryCapabilities, + peers: InMemoryPeers, + } + + impl Harness for MemoryHarness { + fn capabilities(&self) -> &dyn CapabilityStore { + &self.capabilities + } + + fn peers(&self) -> &dyn PeerStore { + &self.peers + } + + fn clock(&self) -> &ManualClock { + &self.clock + } + } + + #[tokio::test] + async fn the_in_memory_stores_conform() { + let clock = Arc::new(ManualClock::default()); + let harness = MemoryHarness { + capabilities: InMemoryCapabilities::new(clock.clone()), + peers: InMemoryPeers::new(), + clock, + }; + conformance::run_all(&harness).await; + } +} diff --git a/capsule-server/src/federation/mod.rs b/capsule-server/src/federation/mod.rs new file mode 100644 index 00000000..6324d347 --- /dev/null +++ b/capsule-server/src/federation/mod.rs @@ -0,0 +1,169 @@ +//! Server-to-server federation (`S-E2`, `S-E5`, `S-C49`): the capability that gates which peer +//! may pull which album, the store it is issued from and revoked into, and the peers this +//! server knows. +//! +//! # No new data protocol +//! +//! design/federation.md is explicit: a peer fetches *exactly* the primitives a client fetches — +//! `GET /v1/sync?album_id=…` and `GET /v1/blob/{hash}` — and what federation adds is the +//! **capability token** those two reads accept in the `Authorization: Bearer` slot, plus the +//! per-peer budget behind it. So there is no `/v1/federation/pull` here and never will be: the +//! pull path is the read path, and this module is the credential, the lifecycle around it +//! (mint, refresh, revoke) and the moderation halves that hang on it (signed report intake, the +//! server-level blocklist). +//! +//! # What lives where +//! +//! - [`capability`] — the EdDSA-JWT and the codec that mints and reads it, over the **same** +//! Ed25519 key the session tokens are signed with, which is the key `server-info` publishes. +//! - [`store`] — [`CapabilityStore`], the record of every capability this server issued. It +//! **is** the revocation list: the adapters implement +//! [`RevocationList`](crate::discovery::revocation::RevocationList) and +//! `/.well-known/capsule/revoked-jti` reads them, so "is this `jti` revoked" has one answer. +//! - [`peers`] — [`PeerStore`], the peers whose signing keys an operator has pinned and the +//! blocklist, which is a column on the same row. +//! - [`memory`] — the deterministic doubles; [`conformance`] — the suite every adapter passes. +//! +//! # A peer is not an account +//! +//! A [`PeerId`] is a server's canonical origin (`other.tld`), never a user id, and the types +//! keep them apart everywhere the two could be confused: the sync cursor's scope byte, the +//! blob authority's principal, the counter key. Nothing here holds a user list, and nothing +//! published here names a user — the registry's no-enumeration rule holds at this layer too. + +use std::fmt; +use std::sync::Arc; + +pub mod capability; +pub mod conformance; +pub mod memory; +pub mod peers; +pub mod store; + +pub use self::capability::{ + ALBUM_URN_PREFIX, CapabilityCodec, CapabilityError, CapabilityGrant, MintError, MintRequest, + Minted, Scope, album_from_urn, album_urn, +}; +pub use self::memory::{InMemoryCapabilities, InMemoryPeers}; +pub use self::peers::{BlockOutcome, PeerRecord, PeerStore, UnblockOutcome}; +pub use self::store::{ + CapabilityFilter, CapabilityRecord, CapabilityStore, RefreshOutcome, RevokeOutcome, +}; +use crate::store::Clock; + +/// A peer server's identity: its canonical origin, as its own `server-info` publishes it. +/// +/// Its own type rather than a `UserId` or a bare string so a peer can never be handed to a port +/// that expects an account, and so the log field that names one reads as what it is. +#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct PeerId(String); + +impl PeerId { + /// Wraps an already-validated origin. + pub fn new(value: impl Into) -> Self { + Self(value.into()) + } + + /// The origin as text. + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl fmt::Display for PeerId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } +} + +impl fmt::Debug for PeerId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "PeerId({:?})", self.0) + } +} + +/// What the federation module is assembled from. +/// +/// Named rather than positional, for the reason [`crate::app::Modules`] is: a constructor that +/// lengthens with every collaborator is one that is eventually got wrong positionally. +#[derive(Debug)] +pub struct FederationCollaborators { + /// Mints and reads capability tokens. + pub codec: Arc, + /// Every capability this server issued, and the revocation list it publishes. + pub capabilities: Arc, + /// The peers this server has pinned or blocked. + pub peers: Arc, + /// The clock every record and every deadline is stamped from. + pub clock: Arc, + /// Where peers reach this server, when it federates at all. + /// + /// `None` is a deployment that does not federate: the lifecycle writes refuse with + /// `error.federation.not_configured`, while a capability minted earlier still verifies — + /// a token is not un-minted by a configuration change. + pub federation_url: Option, +} + +/// The federation module's collaborators. +#[derive(Debug, Clone)] +pub struct FederationContext { + codec: Arc, + capabilities: Arc, + peers: Arc, + clock: Arc, + federation_url: Option, +} + +impl FederationContext { + /// Assembles the module. + pub fn new(collaborators: FederationCollaborators) -> Self { + let FederationCollaborators { + codec, + capabilities, + peers, + clock, + federation_url, + } = collaborators; + Self { + codec, + capabilities, + peers, + clock, + federation_url, + } + } + + /// The codec capabilities are minted with and read by. + pub fn codec(&self) -> &CapabilityCodec { + &self.codec + } + + /// Every capability this server issued. + pub fn capabilities(&self) -> &dyn CapabilityStore { + self.capabilities.as_ref() + } + + /// The peers this server knows. + pub fn peers(&self) -> &dyn PeerStore { + self.peers.as_ref() + } + + /// The clock. + pub fn clock(&self) -> &dyn Clock { + self.clock.as_ref() + } + + /// Where peers reach this server, if it federates. + pub fn federation_url(&self) -> Option<&str> { + self.federation_url.as_deref() + } + + /// Whether this deployment federates at all. + /// + /// The gate on every lifecycle write. Reads are not gated on it: a capability that was + /// minted while federation was on still verifies, and refusing it would cut a peer off + /// without a revocation anybody can see. + pub fn is_configured(&self) -> bool { + self.federation_url.is_some() + } +} diff --git a/capsule-server/src/federation/peers.rs b/capsule-server/src/federation/peers.rs new file mode 100644 index 00000000..32b0fef4 --- /dev/null +++ b/capsule-server/src/federation/peers.rs @@ -0,0 +1,99 @@ +//! [`PeerStore`] — the peer servers this one knows: their pinned signing keys, and the +//! server-level blocklist. +//! +//! # Operator-pinned, not fetched +//! +//! design/federation.md describes peers caching each other's keys TOFU-style with a perspective +//! check on rotation. This server has no outbound HTTP client at all — nothing in +//! `capsule-server` reaches out to another server — so in v1 a peer's key arrives the way a +//! deployment's own key does: an operator puts it there. That is stated rather than worked +//! around because the alternative, fetching `server-info` at report intake, would make the +//! first federated report from a new peer the thing that decides whether it is trusted. +//! +//! **Minting needs no peer key.** A capability is signed with this server's own key, and the +//! peer verifies it against `server-info`. The pinned key serves exactly one thing: verifying +//! the signature on a federated moderation report. +//! +//! # The blocklist is a column +//! +//! design/moderation.md's server-level blocklist "operates at the federation capability layer", +//! and here it is a row's `blocked_at`. Blocking a peer nobody has pinned is legitimate — an +//! operator blocks a server they never wanted to hear from — so a block creates the row without +//! a key. Every federation boundary consults it: mint, presentation, refresh, report intake. + +use std::fmt; + +use jiff::Timestamp; + +use super::PeerId; +use crate::store::StoreFuture; + +/// What this server knows about one peer. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PeerRecord { + /// The peer's canonical origin. + pub server_id: PeerId, + /// Its operational Ed25519 public key, if an operator has pinned one. + pub signing_key: Option<[u8; 32]>, + /// When this server first recorded the peer, by a pin or by a block. + pub first_seen_at: Timestamp, + /// When it was blocked, while it is. + pub blocked_at: Option, + /// The operator's note on the block, if they left one. + pub note: Option, +} + +impl PeerRecord { + /// Whether federated requests from this peer are refused. + pub fn is_blocked(&self) -> bool { + self.blocked_at.is_some() + } +} + +/// What blocking did. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BlockOutcome { + /// The peer is now blocked. + Blocked, + /// It already was. A retry is not a new fact. + AlreadyBlocked, +} + +/// What unblocking did. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum UnblockOutcome { + /// The peer is no longer blocked. + Unblocked, + /// It was not blocked, or was never recorded. + NotBlocked, +} + +/// Where peers are kept. +pub trait PeerStore: fmt::Debug + Send + Sync { + /// Pin `signing_key` as `peer`'s operational key, at `at`. + /// + /// Replaces a key already pinned: rotation is an operator act here. A block already on + /// the row is kept — pinning a key is not an opinion about whether to talk to its owner. + fn pin<'a>( + &'a self, + peer: &'a PeerId, + signing_key: [u8; 32], + at: Timestamp, + ) -> StoreFuture<'a, ()>; + + /// What is known about `peer`, if anything. + fn read<'a>(&'a self, peer: &'a PeerId) -> StoreFuture<'a, Option>; + + /// Refuse federated requests from `peer` from `at`, with `note` for the operator's record. + /// + /// Creates the row if the peer was never pinned. Idempotent. + fn block<'a>( + &'a self, + peer: &'a PeerId, + at: Timestamp, + note: Option, + ) -> StoreFuture<'a, BlockOutcome>; + + /// Lift a block on `peer`. The pinned key, if any, is kept. + fn unblock<'a>(&'a self, peer: &'a PeerId) -> StoreFuture<'a, UnblockOutcome>; +} diff --git a/capsule-server/src/federation/store.rs b/capsule-server/src/federation/store.rs new file mode 100644 index 00000000..8dfd3820 --- /dev/null +++ b/capsule-server/src/federation/store.rs @@ -0,0 +1,174 @@ +//! [`CapabilityStore`] — every capability this server issued, and the revocation list it +//! publishes. +//! +//! # The store is the revocation list +//! +//! `/.well-known/capsule/revoked-jti` was served from a standalone list before federation had a +//! minting side (`S-C18`). Once a capability is a stored record, "is this `jti` revoked" has a +//! second possible answer — the record's `revoked_at` — and two answers to that question is the +//! one shape revocation cannot afford. So every adapter here **is** a +//! [`RevocationList`]: revoking an issued capability sets its `revoked_at` and publishes its +//! `jti` in one critical section, and the standalone in-memory list is gone. +//! +//! [`RevocationList::revoke`] still accepts a `jti` this server never issued, and still +//! publishes it: an operator revoking a token by hand from a peer's report, or a record that +//! predates the store, is a fact the list must carry whether or not a row backs it. +//! +//! # What the record binds that the token does not +//! +//! The token names the peer, the album and the scope. The record adds the **member** the +//! capability was minted for and the **epoch** their membership was granted at +//! ([`CapabilityRecord::granted_epoch`]), so presentation can ask whether that member is still +//! on the roster at that epoch: a member removed and re-admitted later gets a fresh grant, and +//! the old capability — minted for a membership that ended — is refused without anyone having +//! revoked it. The token format is normative and parsed by every peer, which is why the epoch is +//! a stored fact rather than a claim. +//! +//! # Refresh is one operation +//! +//! [`CapabilityStore::refresh`] issues the successor, marks the predecessor as refreshed *to* +//! it, and revokes the predecessor, in one critical section. Idempotency keyed by +//! `(peer, jti)` — threat-model/validation.md — falls out of the `refreshed_to` link: a replay +//! finds the predecessor already refreshed and answers with the same successor, and two +//! concurrent refreshes of one token cannot both issue. The `peer` half of the key is the +//! credential's: only the holder of the predecessor can present it, and the store refuses a +//! successor that names another peer, album or member than the predecessor did, so the link +//! can never widen what was granted. A successor answered to a replay may itself have been +//! revoked since (a block cascades over every live capability of a peer); the route re-checks +//! [`CapabilityRecord::is_live`] before re-signing it. + +use std::fmt; + +use jiff::Timestamp; + +use super::PeerId; +use super::capability::{CapabilityGrant, Scope}; +use crate::discovery::revocation::RevocationList; +use crate::store::{AlbumId, StoreFuture, UserId}; + +/// One capability this server issued. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CapabilityRecord { + /// The token's `jti`, and the revocation key. + pub jti: String, + /// The album it scopes to. + pub album_id: AlbumId, + /// The peer server it was issued to. + pub peer_id: PeerId, + /// The roster member whose access it carries, as the owner listed them. + pub member: UserId, + /// What it permits. + pub scope: Scope, + /// The epoch the member's membership was granted at when this was minted. + pub granted_epoch: u64, + /// The album's pinned protocol date, carried so the grant can be re-signed. + pub min_protocol_version: String, + /// When it was minted; also its `nbf`. + pub issued_at: Timestamp, + /// When it stops being honoured. + pub expires_at: Timestamp, + /// When it was revoked, if it has been. + pub revoked_at: Option, + /// The `jti` of the successor a refresh issued, if one has. + pub refreshed_to: Option, +} + +impl CapabilityRecord { + /// Whether the capability may still be presented at `now`: unrevoked and unexpired. + pub fn is_live(&self, now: Timestamp) -> bool { + self.revoked_at.is_none() && self.expires_at > now + } + + /// The grant this record describes, which the codec re-signs byte-for-byte. + pub fn grant(&self) -> CapabilityGrant { + CapabilityGrant { + peer: self.peer_id.clone(), + album: self.album_id.clone(), + scope: self.scope, + jti: self.jti.clone(), + issued_at: self.issued_at, + expires_at: self.expires_at, + min_protocol_version: self.min_protocol_version.clone(), + } + } +} + +/// Which live capabilities a caller wants. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CapabilityFilter { + /// Every live capability over one album — what a roster change consults. + Album(AlbumId), + /// Every live capability held by one peer — what a block cascades over. + Peer(PeerId), +} + +/// What revoking an issued capability did. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RevokeOutcome { + /// It was live and is now revoked and published. + Revoked, + /// It was already revoked. A retry is not a new fact. + AlreadyRevoked, + /// No capability with that `jti` was ever issued here. + Unknown, +} + +/// What a refresh did. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RefreshOutcome { + /// The successor was issued and the predecessor revoked and linked to it. + Issued(CapabilityRecord), + /// The predecessor had already been refreshed; this is the successor it links to. + AlreadyRefreshed(CapabilityRecord), + /// The predecessor was revoked without a successor, so there is nothing to continue. + Revoked, + /// No capability with the predecessor's `jti` was ever issued here. + Unknown, +} + +/// Where issued capabilities live, and the revocation list they feed. +pub trait CapabilityStore: RevocationList + fmt::Debug + Send + Sync { + /// Record a freshly minted capability. + /// + /// # Errors + /// + /// Returns [`StoreError::Rejected`](crate::store::StoreError::Rejected) if a capability with + /// the same `jti` is already recorded — a `jti` is a fresh UUIDv7 per mint, so a collision is + /// a bug rather than a retry — or if the record would live past the TTL ceiling, which the + /// published list is bounded by. The codec clamps at mint, so the second is a bug too. + fn issue(&self, record: CapabilityRecord) -> StoreFuture<'_, ()>; + + /// The capability `jti` names, revoked or not. + fn find<'a>(&'a self, jti: &'a str) -> StoreFuture<'a, Option>; + + /// Every capability matching `filter` that is live at `now`. + fn live<'a>( + &'a self, + filter: &'a CapabilityFilter, + now: Timestamp, + ) -> StoreFuture<'a, Vec>; + + /// Revoke the capability `jti` names at `at`, and publish its `jti`, in one operation. + /// + /// Idempotent: a second call answers [`RevokeOutcome::AlreadyRevoked`] and changes nothing. + fn revoke_issued<'a>(&'a self, jti: &'a str, at: Timestamp) -> StoreFuture<'a, RevokeOutcome>; + + /// Issue `successor` in place of the capability `predecessor` names, at `at`. + /// + /// One critical section: the successor is recorded, the predecessor's `refreshed_to` is set + /// to it, and the predecessor is revoked and published. A predecessor that has already been + /// refreshed answers [`RefreshOutcome::AlreadyRefreshed`] with the successor it links to and + /// records nothing — which is the idempotency the contract promises. + /// + /// # Errors + /// + /// Returns [`StoreError::Rejected`](crate::store::StoreError::Rejected) if `successor` names + /// a different peer, album or member than the predecessor, or would live past the ceiling, + /// or reuses a recorded `jti`. Every one is a bug in the caller, never a peer's request. + fn refresh<'a>( + &'a self, + predecessor: &'a str, + successor: CapabilityRecord, + at: Timestamp, + ) -> StoreFuture<'a, RefreshOutcome>; +} diff --git a/capsule-server/src/lib.rs b/capsule-server/src/lib.rs index e99fd094..cae1003f 100644 --- a/capsule-server/src/lib.rs +++ b/capsule-server/src/lib.rs @@ -28,7 +28,8 @@ //! //! Each module owns one port and, where it has one, the surface over it. [`routes`] is the only //! module that knows about HTTP: everything under it — [`album`], [`directory`], [`discovery`], -//! [`enrollment`], [`escrow`], [`gc`], [`index`], [`membership`], [`moderation`], +//! [`enrollment`], [`escrow`], [`federation`], [`gc`], [`index`], [`membership`], +//! [`moderation`], //! [`negotiation`], [`quota`], //! [`scrub`], [`serve`], //! [`share`], [`store`], @@ -76,6 +77,7 @@ pub mod discovery; pub mod drop; pub mod enrollment; pub mod escrow; +pub mod federation; pub mod gc; pub mod index; pub mod limits; diff --git a/capsule-server/tests/support/mod.rs b/capsule-server/tests/support/mod.rs index cd9f25f2..43a8f073 100644 --- a/capsule-server/tests/support/mod.rs +++ b/capsule-server/tests/support/mod.rs @@ -53,7 +53,7 @@ use capsule_server::directory::{ PublishedDirectory, }; use capsule_server::discovery::revocation::{ - InMemoryRevocations, PublishedRevocations, RevocationList, RevokeFuture, RevokedToken, + PublishedRevocations, RevocationList, RevokeFuture, RevokedToken, }; use capsule_server::discovery::{DiscoveryContext, ProtocolWindow, ServerInfo}; use capsule_server::drop::{ @@ -61,6 +61,10 @@ use capsule_server::drop::{ }; use capsule_server::enrollment::EnrollmentContext; use capsule_server::escrow::{EscrowContext, EscrowRecord, EscrowStore, InMemoryEscrow, Replaced}; +use capsule_server::federation::{ + CapabilityCodec, CapabilityFilter, CapabilityRecord, CapabilityStore, FederationCollaborators, + FederationContext, InMemoryCapabilities, InMemoryPeers, RefreshOutcome, RevokeOutcome, +}; use capsule_server::gc::memory::InMemoryCollection; use capsule_server::index::memory::InMemoryAssetIndex; use capsule_server::index::{ @@ -196,6 +200,9 @@ pub(crate) fn identity_header(ik: &HybridSigningKey) -> String { /// asserting about one fact rather than two matching literals. pub(crate) const SERVER_ORIGIN: &str = "capsule.test"; +/// Where peers pull from, in every fixture that federates. The API base, as the design has it. +pub(crate) const FEDERATION_URL: &str = "https://capsule.test/v1"; + /// The account [`Fixture::working`] seeds. pub(crate) const EMAIL: &str = "somebody@example.test"; @@ -2106,24 +2113,28 @@ impl AlbumStore for SwitchableAlbums { } } -/// A revocation list that can be made to fail on demand. +/// A capability store — and therefore a revocation list — that can be made to fail on demand. /// -/// Delegates to a real in-memory list, so the failing case and the working case differ in +/// Delegates to the real in-memory store, so the failing case and the working case differ in /// exactly one thing. It exists because `503` on the published record is a *claim*: the /// endpoint refuses to serve an empty list on a storage failure, since an empty list is the /// strongest statement the record can make and serving it during an outage would silently /// un-revoke every token a peer holds. A status nothing can reach is a status nothing proves. +/// +/// One object behind two ports, exactly as `boot` wires it: discovery reads it as the list and +/// federation writes it as the store, so a revocation the federation layer records is the one +/// `revoked-jti` publishes. #[derive(Debug)] pub(crate) struct SwitchableRevocations { - inner: InMemoryRevocations, + inner: InMemoryCapabilities, unavailable: AtomicBool, } impl SwitchableRevocations { - /// A working list reading `clock` for pruning. + /// A working store reading `clock` for pruning. pub(crate) fn new(clock: Arc) -> Self { Self { - inner: InMemoryRevocations::new(clock), + inner: InMemoryCapabilities::new(clock), unavailable: AtomicBool::new(false), } } @@ -2161,6 +2172,52 @@ impl RevocationList for SwitchableRevocations { } } +impl CapabilityStore for SwitchableRevocations { + fn issue(&self, record: CapabilityRecord) -> StoreFuture<'_, ()> { + if self.is_down() { + return Box::pin(async { Self::refuse() }); + } + self.inner.issue(record) + } + + fn find<'a>(&'a self, jti: &'a str) -> StoreFuture<'a, Option> { + if self.is_down() { + return Box::pin(async { Self::refuse() }); + } + self.inner.find(jti) + } + + fn live<'a>( + &'a self, + filter: &'a CapabilityFilter, + now: Timestamp, + ) -> StoreFuture<'a, Vec> { + if self.is_down() { + return Box::pin(async { Self::refuse() }); + } + self.inner.live(filter, now) + } + + fn revoke_issued<'a>(&'a self, jti: &'a str, at: Timestamp) -> StoreFuture<'a, RevokeOutcome> { + if self.is_down() { + return Box::pin(async { Self::refuse() }); + } + self.inner.revoke_issued(jti, at) + } + + fn refresh<'a>( + &'a self, + predecessor: &'a str, + successor: CapabilityRecord, + at: Timestamp, + ) -> StoreFuture<'a, RefreshOutcome> { + if self.is_down() { + return Box::pin(async { Self::refuse() }); + } + self.inner.refresh(predecessor, successor, at) + } +} + /// A device-directory store that can be made to fail on demand. /// /// Delegates to a real in-memory store, so the failing case and the working case differ in @@ -2506,8 +2563,13 @@ pub(crate) struct Fixture { /// The attestation key the server signs receipts with — the *same* one, so a test can /// verify a fetched receipt the way a client would. pub(crate) attestation_key: Arc, - /// The federation capability revocations this server publishes. + /// The federation capabilities this server issued, and the revocations it publishes. pub(crate) revocations: Arc, + /// The peers this server has pinned or blocked. + pub(crate) peers: Arc, + /// The capability codec the server mints with — the *same* one, over the *same* key as + /// `tokens`, so a test can mint a capability the server will accept, or one it must not. + pub(crate) codec: Arc, /// The single-use revoke-all challenges. pub(crate) challenges: Arc, /// The account's wrapped master key. @@ -2548,7 +2610,14 @@ impl Fixture { let sessions = Arc::new(SwitchableSessions::new(clock.clone())); let accounts = Arc::new(InMemoryAccounts::new()); accounts.insert(EMAIL, PASSWORD, &user()); - let tokens = Arc::new(signer(clock.clone())); + // One key pair for both token types, as `boot` wires it: the capability a peer verifies + // against `server-info`'s key is signed by the key that signs sessions. + let der = signing_key_der(); + let tokens = Arc::new(signer_from(&der, clock.clone())); + let codec = Arc::new( + CapabilityCodec::from_pkcs8(&der, SERVER_ORIGIN, clock.clone()) + .expect("a key just generated parses"), + ); let uploads = Arc::new(SwitchableUploads::new(clock.clone())); let blobs = Arc::new(SwallowingBlobs::new()); @@ -2577,6 +2646,7 @@ impl Fixture { capsule_core::crypto::keys::HybridSigningKey::generate(), )); let revocations = Arc::new(SwitchableRevocations::new(clock.clone())); + let peers = Arc::new(InMemoryPeers::new()); let challenges = Arc::new(SwitchableChallenges::new(clock.clone())); let escrows = Arc::new(SwitchableEscrow::new()); let cohorts = Arc::new(SwitchableCohorts::new()); @@ -2645,6 +2715,15 @@ impl Fixture { ), discovery: DiscoveryContext::new(Arc::new(server_info(&tokens)), revocations.clone()), escrow: EscrowContext::new(escrows.clone(), clock.clone()), + // Configured, so the lifecycle writes are reachable; the cases about a deployment + // that does not federate build their own context. + federation: FederationContext::new(FederationCollaborators { + codec: codec.clone(), + capabilities: revocations.clone(), + peers: peers.clone(), + clock: clock.clone(), + federation_url: Some(FEDERATION_URL.to_owned()), + }), enrollment: EnrollmentContext::new( enrollments.clone(), channels.clone(), @@ -2685,6 +2764,8 @@ impl Fixture { receipts, attestation_key, revocations, + peers, + codec, challenges, escrows, cohorts, @@ -2728,7 +2809,9 @@ impl Fixture { let index = Arc::new(SwitchableIndex::new()); let members = Arc::new(InMemoryMembership::new()); let albums = Arc::new(SwitchableAlbums::new()); - let tokens = Arc::new(signer(clock.clone())); + let der = signing_key_der(); + let tokens = Arc::new(signer_from(&der, clock.clone())); + let issued = Arc::new(SwitchableRevocations::new(clock.clone())); let app = App::new(Modules { auth: AuthContext::new(AuthCollaborators { sessions: Arc::new(SwitchableSessions::new(clock.clone())), @@ -2788,11 +2871,18 @@ impl Fixture { )), Timestamp::UNIX_EPOCH, ), - discovery: DiscoveryContext::new( - Arc::new(server_info(&tokens)), - Arc::new(SwitchableRevocations::new(clock.clone())), - ), + discovery: DiscoveryContext::new(Arc::new(server_info(&tokens)), issued.clone()), escrow: EscrowContext::new(Arc::new(SwitchableEscrow::new()), clock.clone()), + federation: FederationContext::new(FederationCollaborators { + codec: Arc::new( + CapabilityCodec::from_pkcs8(&der, SERVER_ORIGIN, clock.clone()) + .expect("a key just generated parses"), + ), + capabilities: issued, + peers: Arc::new(InMemoryPeers::new()), + clock: clock.clone(), + federation_url: Some(FEDERATION_URL.to_owned()), + }), enrollment: EnrollmentContext::new( Arc::new(InMemoryEnrollments::new(clock.clone(), ENROLLMENT_CODE_TTL)), Arc::new(InMemoryChannels::new(clock.clone(), RELAY_CHANNEL_TTL)), @@ -3064,10 +3154,23 @@ pub(crate) fn server_info(tokens: &SessionTokens) -> ServerInfo { } pub(crate) fn signer(clock: Arc) -> SessionTokens { - let der = ring::signature::Ed25519KeyPair::generate_pkcs8(&ring::rand::SystemRandom::new()) - .expect("the platform can generate an Ed25519 key"); + signer_from(&signing_key_der(), clock) +} - SessionTokens::from_pkcs8(der.as_ref(), clock).expect("a key just generated parses") +/// A freshly generated PKCS#8 Ed25519 private key. +/// +/// One of these backs both the session signer and the capability codec of a fixture, because +/// that is the one-key invariant `boot` holds: the key `server-info` publishes signs both. +pub(crate) fn signing_key_der() -> Vec { + ring::signature::Ed25519KeyPair::generate_pkcs8(&ring::rand::SystemRandom::new()) + .expect("the platform can generate an Ed25519 key") + .as_ref() + .to_vec() +} + +/// The session signer over `der`. +pub(crate) fn signer_from(der: &[u8], clock: Arc) -> SessionTokens { + SessionTokens::from_pkcs8(der, clock).expect("a key just generated parses") } /// A `POST /v1/upload` body for one member of a **replace** bundle (`S-C43`). diff --git a/capsule-swift/Generated/Localizable.xcstrings b/capsule-swift/Generated/Localizable.xcstrings index 283615e4..30d72727 100644 --- a/capsule-swift/Generated/Localizable.xcstrings +++ b/capsule-swift/Generated/Localizable.xcstrings @@ -40219,6 +40219,36 @@ } } }, + "error.federation.member_not_on_roster": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "That person isn't on this album's member list." + } + } + } + }, + "error.federation.not_configured": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This server doesn't share albums with other servers." + } + } + } + }, + "error.federation.peer_unknown": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "That server isn't one this server knows." + } + } + } + }, "error.federation.rate_budget_exceeded": { "localizations": { "ar": { @@ -40393,6 +40423,16 @@ } } }, + "error.federation.unavailable": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Capsule couldn't reach the federation records. Please try again." + } + } + } + }, "error.moderation.account_suspended": { "localizations": { "ar": { diff --git a/capsule-web/src/i18n/messages/en.json b/capsule-web/src/i18n/messages/en.json index 3c02cc0c..49ae417f 100644 --- a/capsule-web/src/i18n/messages/en.json +++ b/capsule-web/src/i18n/messages/en.json @@ -1894,9 +1894,13 @@ "error.federation.capability_invalid": "This shared album's access could not be verified.", "error.federation.capability_revoked": "Access to this shared album has been revoked.", "error.federation.circuit_open": "This source is temporarily backed off after repeated errors.", + "error.federation.member_not_on_roster": "That person isn't on this album's member list.", + "error.federation.not_configured": "This server doesn't share albums with other servers.", + "error.federation.peer_unknown": "That server isn't one this server knows.", "error.federation.rate_budget_exceeded": "This source has reached its request limit. Please wait and try again.", "error.federation.revocations_unavailable": "Capsule couldn't read the revocation list. Please try again.", "error.federation.scope_insufficient": "This access grant does not cover the requested content.", + "error.federation.unavailable": "Capsule couldn't reach the federation records. Please try again.", "error.moderation.account_suspended": "Your account is suspended. You can't upload or share until it's reinstated.", "error.moderation.report_rate_limited": "Too many reports from this source. Please wait and try again.", "error.moderation.report_unsigned": "The moderation report could not be verified.", diff --git a/locales/en.json b/locales/en.json index 5387b650..10d5c470 100644 --- a/locales/en.json +++ b/locales/en.json @@ -7579,6 +7579,18 @@ "message": "This source is temporarily backed off after repeated errors.", "context": "HTTP 429 on a federation pull (slice S-E2): the peer spent its per-peer error budget on malformed input, tripping a circuit breaker that backs it off exponentially (5 / 30 / 60 minutes). Requests are short-circuited until the back-off elapses, so a buggy peer cannot DoS the server." }, + "error.federation.member_not_on_roster": { + "message": "That person isn't on this album's member list.", + "context": "HTTP 409 on POST /v1/albums/{album_id}/capabilities (slice S-E2): the account the capability would carry is not on the album's current roster, so there is no membership to grant a peer access for. A stale-state class: the owner publishes the roster that lists them first, then mints." + }, + "error.federation.not_configured": { + "message": "This server doesn't share albums with other servers.", + "context": "HTTP 403 on the federation lifecycle writes — minting, revoking or refreshing a capability, and federated report intake (slice S-E2): this deployment has no FEDERATION_URL, so it does not federate. A capability minted while it did still verifies; configuration does not un-mint a token." + }, + "error.federation.peer_unknown": { + "message": "That server isn't one this server knows.", + "context": "HTTP 403 on POST /v1/federation/reports (slice S-C49): the reporting server has no pinned signing key here, so its report cannot be verified and is dropped before the admin queue. Peer keys are operator-pinned in v1; there is no network fetch." + }, "error.federation.rate_budget_exceeded": { "message": "This source has reached its request limit. Please wait and try again.", "context": "HTTP 429 on a federation pull (slice S-E2, threat-model invariant 21): the peer exceeded one of its per-peer transfer budgets — events/hour, bytes/hour, or CPU/hour. Each peer is its own blast-radius boundary; a busy or hostile peer cannot starve good ones." @@ -7591,6 +7603,10 @@ "message": "This access grant does not cover the requested content.", "context": "HTTP 403 on a federation blob fetch (slice S-E2, threat-model invariant 19): a read-derivative-only capability tried to fetch a blob whose server-visible role is 'original'. Scope is enforced structurally against each blob's role." }, + "error.federation.unavailable": { + "message": "Capsule couldn't reach the federation records. Please try again.", + "context": "HTTP 500 on the federation surface (slice S-E2): the capability store, the peer store or the per-peer counter could not answer, so nothing was decided — never a refusal and never an admission. A limiter that cannot be reached is an outage, not a limit." + }, "error.moderation.account_suspended": { "message": "Your account is suspended. You can't upload or share until it's reinstated.", "context": "HTTP 403 at POST /upload session creation (slice S-C8): the account carries an admin/billing suspension flag. Distinct from quota and permission rejections so the client surfaces the right remediation. Suspension is an access-level action — the user's data is untouched and the block is reversible."