Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions capsule-android/src/androidMain/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -1885,9 +1885,13 @@
<string name="error_federation_capability_invalid">This shared album\'s access could not be verified.</string>
<string name="error_federation_capability_revoked">Access to this shared album has been revoked.</string>
<string name="error_federation_circuit_open">This source is temporarily backed off after repeated errors.</string>
<string name="error_federation_member_not_on_roster">That person isn\'t on this album\'s member list.</string>
<string name="error_federation_not_configured">This server doesn\'t share albums with other servers.</string>
<string name="error_federation_peer_unknown">That server isn\'t one this server knows.</string>
<string name="error_federation_rate_budget_exceeded">This source has reached its request limit. Please wait and try again.</string>
<string name="error_federation_revocations_unavailable">Capsule couldn\'t read the revocation list. Please try again.</string>
<string name="error_federation_scope_insufficient">This access grant does not cover the requested content.</string>
<string name="error_federation_unavailable">Capsule couldn\'t reach the federation records. Please try again.</string>
<string name="error_moderation_account_suspended">Your account is suspended. You can\'t upload or share until it\'s reinstated.</string>
<string name="error_moderation_report_rate_limited">Too many reports from this source. Please wait and try again.</string>
<string name="error_moderation_report_unsigned">The moderation report could not be verified.</string>
Expand Down
1 change: 0 additions & 1 deletion capsule-docs/planned-modules.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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.
4 changes: 4 additions & 0 deletions capsule-i18n/src/bundles/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
12 changes: 12 additions & 0 deletions capsule-i18n/src/generated.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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";

Expand Down
8 changes: 8 additions & 0 deletions capsule-server/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
7 changes: 7 additions & 0 deletions capsule-server/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -160,6 +165,7 @@ impl App {
attestation,
discovery,
escrow,
federation,
enrollment,
moderation,
share,
Expand All @@ -180,6 +186,7 @@ impl App {
attestation,
discovery,
escrow,
federation,
enrollment,
moderation,
share,
Expand Down
101 changes: 94 additions & 7 deletions capsule-server/src/boot.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -470,15 +473,34 @@ fn memory(config: &Config, stores: Stores) -> Result<Assembled, BootError> {
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 {
min: config.protocol_min.clone(),
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 {
Expand Down Expand Up @@ -540,11 +562,15 @@ fn memory(config: &Config, stores: Stores) -> Result<Assembled, BootError> {
// 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())),
Expand Down Expand Up @@ -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");
Expand Down
11 changes: 11 additions & 0 deletions capsule-server/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
/// The filesystem tree ciphertext blobs are written to. There is no object store.
pub blob_root: Option<PathBuf>,
/// The Postgres URL, once an adapter reads it (#402).
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -614,6 +624,7 @@ impl Config {
listen,
server_domain,
api_base_url,
federation_url,
blob_root,
database_url,
valkey_url,
Expand Down
89 changes: 10 additions & 79 deletions capsule-server/src/discovery/revocation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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<BTreeMap<String, Timestamp>>,
clock: Arc<dyn Clock>,
}

impl InMemoryRevocations {
/// An empty list reading `clock` for pruning and for `generated_at`.
pub fn new(clock: Arc<dyn Clock>) -> 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<RevokedToken> = 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 {
Expand Down
Loading
Loading