diff --git a/Cargo.lock b/Cargo.lock index 2eae9e407..d59675bd1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3961,6 +3961,7 @@ dependencies = [ "rfd", "serde", "serde_json", + "sha2", "skia-safe", "socket2 0.6.3", "static_assertions", diff --git a/crates/op-host-desktop/Cargo.toml b/crates/op-host-desktop/Cargo.toml index bcd63a197..c65e722b8 100644 --- a/crates/op-host-desktop/Cargo.toml +++ b/crates/op-host-desktop/Cargo.toml @@ -97,6 +97,7 @@ op-collab-relay-client = { path = "../op-collab-relay-client" } op-collab-relay-control-plane = { path = "../op-collab-relay-control-plane" } op-collab-relay-protocol = { path = "../op-collab-relay-protocol" } ed25519-dalek = { version = "2.2", default-features = false, features = ["std"] } +sha2 = "0.10" zeroize = "1" # D′ architecture: pre-validation adapter translates PlannedFix → EditorCommand. op-design-lint = { path = "../op-design-lint" } diff --git a/crates/op-host-desktop/src/collab_runtime.rs b/crates/op-host-desktop/src/collab_runtime.rs index 3b9000713..957a57c31 100644 --- a/crates/op-host-desktop/src/collab_runtime.rs +++ b/crates/op-host-desktop/src/collab_runtime.rs @@ -1,5 +1,4 @@ //! GUI-owned collaboration actors bridged to network workers by bounded channels. - mod actor; mod admission; mod auth; @@ -8,6 +7,7 @@ mod effects_wire; mod network; mod poll; pub(crate) mod relay; +mod relay_bootstrap; mod support; mod types; diff --git a/crates/op-host-desktop/src/collab_runtime/network/guest.rs b/crates/op-host-desktop/src/collab_runtime/network/guest.rs index ba1ce6ac2..28d970088 100644 --- a/crates/op-host-desktop/src/collab_runtime/network/guest.rs +++ b/crates/op-host-desktop/src/collab_runtime/network/guest.rs @@ -250,7 +250,7 @@ fn run_inner( ), GuestConnectionRoute::Relay(request) => { let running = GuestRelayRuntime::start(request, Arc::clone(&key), Arc::clone(&local))?; - let target = relay_guest_target(request, &running); + let target = relay_guest_target(&running); relay_runtime = Some(running); (target.0, target.1, target.2, true) } diff --git a/crates/op-host-desktop/src/collab_runtime/relay.rs b/crates/op-host-desktop/src/collab_runtime/relay.rs index 2210d219d..dcbf10558 100644 --- a/crates/op-host-desktop/src/collab_runtime/relay.rs +++ b/crates/op-host-desktop/src/collab_runtime/relay.rs @@ -1,42 +1,34 @@ -use std::collections::HashMap; use std::net::{Ipv4Addr, SocketAddr, TcpListener, TcpStream}; use std::num::NonZeroU64; +use std::sync::Arc; -use base64::engine::general_purpose::URL_SAFE_NO_PAD; -use base64::Engine as _; -use ed25519_dalek::{Signature, VerifyingKey}; use op_auth_bridge::OpaqueCollabTicket; use op_collab::{Epoch, SessionId}; -use op_collab_relay_client::{ - PinnedRelayX25519Keys, RelayEndpoint, RelayGuestBridge, RelayHandshake, RelayOwnerBridge, - RelayServerX25519PublicKey, DEFAULT_OWNER_LANE_COUNT, MAX_PINNED_RELAY_X25519_KEYS, -}; +#[cfg(any(test, debug_assertions))] +use op_collab_relay_client::DEFAULT_OWNER_LANE_COUNT; +use op_collab_relay_client::{RelayEndpoint, RelayGuestBridge, RelayHandshake, RelayOwnerBridge}; use op_collab_relay_control_plane::{ OwnerPublishDraft, RelayLocatorHttpClient, RelayPublishLifetime, }; use op_collab_relay_protocol::{ - ExpectedDiscoveryId, LocatorKeyId, LocatorSignature, OwnerNoiseStatic, RelayChallengeKeyId, - RelayInviteV1, RelayLocatorVerifier, RelayRegion, RouteCapability, RouteId, - UnsignedRelayLocatorV1, VerifiedRelayRoute, MAX_PAIRING_LIFETIME_SECS, + ExpectedDiscoveryId, LocatorKeyId, LocatorSignature, OwnerNoiseStatic, RelayInviteV1, + RelayLocatorVerifier, RelayRegion, RouteCapability, RouteId, UnsignedRelayLocatorV1, + VerifiedRelayRoute, MAX_PAIRING_LIFETIME_SECS, }; use op_collab_transport::{DeviceStaticKey, ServerPrelude}; use op_editor_core::{CollabConnectionPathUi, CollabInviteCode, CollabRelayRegion}; use super::auth::{unix_time_ms, LocalAdmission}; +#[cfg(test)] +use super::relay_bootstrap::RelayBootstrap; +use super::relay_bootstrap::{ + provider_from_environment, Ed25519LocatorVerifier, RelayBootstrapProvider, RelayBootstrapRegion, +}; use super::types::{CollabRuntimeError, CollabRuntimeFailure}; -const RELAY_CN_URL_ENV: &str = "OPENPENCIL_COLLAB_RELAY_CN_URL"; -const RELAY_GLOBAL_URL_ENV: &str = "OPENPENCIL_COLLAB_RELAY_GLOBAL_URL"; const RELAY_HOME_REGION_ENV: &str = "OPENPENCIL_COLLAB_RELAY_HOME_REGION"; -const RELAY_LOCATOR_KEYS_ENV: &str = "OPENPENCIL_COLLAB_RELAY_LOCATOR_KEYS"; -const RELAY_LOCATOR_URL_ENV: &str = "OPENPENCIL_COLLAB_RELAY_LOCATOR_URL"; -const RELAY_X25519_KEYS_ENV: &str = "OPENPENCIL_COLLAB_RELAY_X25519_KEYS"; #[cfg(any(test, debug_assertions))] const RELAY_DEV_UNSIGNED_ENV: &str = "OPENPENCIL_COLLAB_RELAY_DEV_UNSIGNED"; -#[cfg(any(test, debug_assertions))] -const RELAY_LOCATOR_DEV_HTTP_ENV: &str = "OPENPENCIL_COLLAB_RELAY_LOCATOR_DEV_HTTP"; -const MAX_RELAY_ENV_BYTES: usize = 8 * 1024; -const MAX_RELAY_LOCATOR_KEYS: usize = 32; const DEBUG_LOCATOR_KEY_ID: &str = "openpencil-debug-unsigned"; const OWNER_RELAY_READY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(22); @@ -93,16 +85,14 @@ impl GuestConnectionRoute { #[derive(Clone)] pub(super) struct RelayGuestRequest { - endpoint: RelayEndpoint, - route: VerifiedRelayRoute, + invite: RelayInviteV1, home_region: RelayRegion, - development_unsigned: bool, + provider: std::sync::Arc, } pub(super) struct RelayOwnerRequest { - endpoint: RelayEndpoint, home_region: RelayRegion, - development_unsigned: bool, + provider: std::sync::Arc, control_plane: std::sync::Arc, } @@ -117,6 +107,7 @@ pub(crate) trait RelayLocatorControlPlane: Send + Sync { &self, draft: OwnerPublishDraft, ticket: &OpaqueCollabTicket, + region: &RelayBootstrapRegion, ) -> Result; } @@ -127,10 +118,10 @@ impl RelayLocatorControlPlane for EnvironmentRelayLocatorControlPlane { &self, draft: OwnerPublishDraft, ticket: &OpaqueCollabTicket, + region: &RelayBootstrapRegion, ) -> Result { - let endpoint = bounded_environment_value(RELAY_LOCATOR_URL_ENV)?; - let verifier = locator_verifier_from_environment().map_err(|error| error.failure)?; - let client = locator_http_client(&endpoint, verifier.clone())?; + let verifier = region.locator_verifier.clone(); + let client = locator_http_client(region)?; let published = client .publish(draft, ticket) .map_err(|_| CollabRuntimeFailure::RelayUnavailable)?; @@ -158,6 +149,13 @@ impl OwnerRelayRuntime { session_id: &SessionId, epoch: Epoch, ) -> Result { + // The owner network worker resolves one signed bootstrap snapshot and + // uses that same region entry for locator verification, relay pinning, + // and the bridge endpoint. No HTTP runs on the UI/event-loop thread. + let bootstrap = request.provider.load()?; + let region = bootstrap.region(request.home_region)?; + let endpoint = region.relay_endpoint.clone(); + let development_unsigned = development_unsigned_allowed(&endpoint); let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)) .map_err(|_| CollabRuntimeFailure::RelayUnavailable)?; listener @@ -172,22 +170,22 @@ impl OwnerRelayRuntime { .map_err(|_| CollabRuntimeFailure::RelayUnavailable)?, ); let route = owner_route( - request.home_region, *key.public_key(), discovery_id, epoch, - request.development_unsigned, + development_unsigned, request.control_plane.as_ref(), + region, &local, )?; - let authenticator = if request.development_unsigned { + let authenticator = if development_unsigned { None } else { Some( LocalAdmission::challenge_bound_relay_authenticator( std::sync::Arc::clone(&local), std::sync::Arc::clone(&key), - relay_x25519_keys_from_environment()?, + Arc::clone(®ion.relay_x25519_keys), ) .map_err(|error| error.failure)?, ) @@ -198,11 +196,11 @@ impl OwnerRelayRuntime { .map_err(|error| error.failure)?; let handshake = RelayHandshake::new(route, auth); let bridge = op_host_services::chat_runtime::block_on_anywhere(async move { - let bridge = if request.development_unsigned { - start_development_owner_bridge(request.endpoint, handshake, local_addr).await? + let bridge = if development_unsigned { + start_development_owner_bridge(endpoint, handshake, local_addr).await? } else { RelayOwnerBridge::start_default_lanes( - request.endpoint, + endpoint, handshake, local_addr, authenticator.ok_or(CollabRuntimeFailure::RelayUnavailable)?, @@ -256,6 +254,8 @@ impl std::fmt::Debug for OwnerRelayRuntime { pub(super) struct GuestRelayRuntime { local_addr: SocketAddr, + expected_discovery_id: String, + expected_remote_static: [u8; 32], bridge: RelayGuestBridge, } @@ -265,11 +265,38 @@ impl GuestRelayRuntime { key: std::sync::Arc, local: std::sync::Arc>, ) -> Result { + // Resolve and verify the invite on the guest network worker. The UI + // only parses enough of the bounded invite to select its claimed + // region and render status. + let bootstrap = request.provider.load()?; + let region = bootstrap.region(request.home_region)?; + let endpoint = region.relay_endpoint.clone(); + let development_unsigned = development_unsigned_allowed(&endpoint); + let now = unix_time_ms().map_err(|_| CollabRuntimeFailure::RelayUnavailable)? / 1_000; + let route = if development_unsigned { + request + .invite + .verify(&AcceptAllDevelopmentLocator, now) + .map_err(|_| CollabRuntimeFailure::RelayInviteUnavailable)? + } else { + request + .invite + .verify(®ion.locator_verifier, now) + .map_err(|_| CollabRuntimeFailure::RelayInviteUnavailable)? + }; + if route.locator().claims().home_region() != request.home_region { + return Err(CollabRuntimeFailure::RelayInviteUnavailable); + } + let expected_discovery_id = route + .locator() + .claims() + .expected_discovery_id() + .as_str() + .to_owned(); + let expected_remote_static = *route.locator().claims().owner_noise_static().as_bytes(); let auth = LocalAdmission::relay_auth_extension(*key.public_key()) .map_err(|error| error.failure)?; - let handshake = RelayHandshake::new(request.route.clone(), auth); - let endpoint = request.endpoint.clone(); - let development_unsigned = request.development_unsigned; + let handshake = RelayHandshake::new(route, auth); let authenticator = if development_unsigned { None } else { @@ -277,7 +304,7 @@ impl GuestRelayRuntime { LocalAdmission::challenge_bound_relay_authenticator( local, key, - relay_x25519_keys_from_environment()?, + Arc::clone(®ion.relay_x25519_keys), ) .map_err(|error| error.failure)?, ) @@ -296,7 +323,12 @@ impl GuestRelayRuntime { } })?; let local_addr = bridge.local_addr(); - Ok(Self { local_addr, bridge }) + Ok(Self { + local_addr, + expected_discovery_id, + expected_remote_static, + bridge, + }) } pub(super) const fn local_addr(&self) -> SocketAddr { @@ -317,18 +349,13 @@ impl std::fmt::Debug for GuestRelayRuntime { pub(super) fn owner_request_from_environment( control_plane: std::sync::Arc, ) -> Result, CollabRuntimeError> { - if std::env::var_os(RELAY_CN_URL_ENV).is_none() - && std::env::var_os(RELAY_GLOBAL_URL_ENV).is_none() - { + let Some(provider) = provider_from_environment()? else { return Ok(None); - } + }; let home_region = parse_home_region(std::env::var(RELAY_HOME_REGION_ENV).ok().as_deref())?; - let endpoint = endpoint_for_region_from_environment(home_region)?; - let development_unsigned = development_unsigned_allowed(&endpoint); Ok(Some(RelayOwnerRequest { - endpoint, home_region, - development_unsigned, + provider, control_plane, })) } @@ -338,64 +365,33 @@ pub(super) fn guest_route_from_invite( ) -> Result { let invite = RelayInviteV1::from_fragment(invite) .map_err(|_| runtime_error(CollabRuntimeFailure::RelayInviteUnavailable))?; - let now = - unix_time_ms().map_err(|_| runtime_error(CollabRuntimeFailure::RelayUnavailable))? / 1_000; - if development_unsigned_environment_value().as_deref() == Some("1") { - let claimed_region = invite.locator().claims().home_region(); - let endpoint = endpoint_for_region_from_environment(claimed_region)?; - if development_unsigned_allowed(&endpoint) { - let route = invite - .verify(&AcceptAllDevelopmentLocator, now) - .map_err(|_| runtime_error(CollabRuntimeFailure::RelayInviteUnavailable))?; - return Ok(GuestConnectionRoute::Relay(Box::new(RelayGuestRequest { - endpoint, - route, - home_region: claimed_region, - development_unsigned: true, - }))); - } - } - let verifier = locator_verifier_from_environment()?; - let route = invite - .verify(&verifier, now) - .map_err(|_| runtime_error(CollabRuntimeFailure::RelayInviteUnavailable))?; - let region = route.locator().claims().home_region(); - let endpoint = endpoint_for_region_from_environment(region)?; - Ok(GuestConnectionRoute::Relay(Box::new(RelayGuestRequest { - endpoint, - route, + let provider = provider_from_environment()? + .ok_or_else(|| runtime_error(CollabRuntimeFailure::RelayUnavailable))?; + Ok(guest_route_from_parsed_invite(invite, provider)) +} + +fn guest_route_from_parsed_invite( + invite: RelayInviteV1, + provider: Arc, +) -> GuestConnectionRoute { + let region = invite.locator().claims().home_region(); + GuestConnectionRoute::Relay(Box::new(RelayGuestRequest { + invite, home_region: region, - development_unsigned: false, - }))) + provider, + })) } pub(super) fn relay_guest_target( - request: &RelayGuestRequest, relay: &GuestRelayRuntime, ) -> (Vec, Option, Option<[u8; 32]>) { - let claims = request.route.locator().claims(); ( vec![relay.local_addr()], - Some(claims.expected_discovery_id().as_str().to_owned()), - Some(*claims.owner_noise_static().as_bytes()), + Some(relay.expected_discovery_id.clone()), + Some(relay.expected_remote_static), ) } -fn endpoint_from_environment( - name: &'static str, -) -> Result, CollabRuntimeError> { - let Some(value) = std::env::var_os(name) else { - return Ok(None); - }; - let value = value - .to_str() - .filter(|value| !value.is_empty() && value.len() <= MAX_RELAY_ENV_BYTES) - .ok_or_else(|| runtime_error(CollabRuntimeFailure::RelayUnavailable))?; - RelayEndpoint::parse(value) - .map(Some) - .map_err(|_| runtime_error(CollabRuntimeFailure::RelayUnavailable)) -} - fn parse_home_region(value: Option<&str>) -> Result { match value { Some("cn") => Ok(RelayRegion::Cn), @@ -404,152 +400,31 @@ fn parse_home_region(value: Option<&str>) -> Result, - global: Option<&str>, -) -> Result { - let endpoint = match region { - RelayRegion::Cn => cn, - RelayRegion::Global => global, - } - .ok_or_else(|| runtime_error(CollabRuntimeFailure::RelayRegionUnavailable))?; - RelayEndpoint::parse(endpoint) - .map_err(|_| runtime_error(CollabRuntimeFailure::RelayUnavailable)) -} - -fn endpoint_for_region_from_environment( - region: RelayRegion, -) -> Result { - let name = match region { - RelayRegion::Cn => RELAY_CN_URL_ENV, - RelayRegion::Global => RELAY_GLOBAL_URL_ENV, - }; - endpoint_from_environment(name)? - .ok_or_else(|| runtime_error(CollabRuntimeFailure::RelayRegionUnavailable)) -} - -#[derive(Clone)] -struct Ed25519LocatorVerifier { - keys: HashMap, -} - -impl RelayLocatorVerifier for Ed25519LocatorVerifier { - fn verify( - &self, - key_id: &LocatorKeyId, - canonical_signing_bytes: &[u8], - signature: &[u8; 64], - ) -> bool { - let Some(key) = self.keys.get(key_id.as_str()) else { - return false; - }; - key.verify_strict(canonical_signing_bytes, &Signature::from_bytes(signature)) - .is_ok() - } -} - -fn locator_verifier_from_environment() -> Result { - let raw = std::env::var(RELAY_LOCATOR_KEYS_ENV) - .ok() - .filter(|raw| !raw.is_empty() && raw.len() <= MAX_RELAY_ENV_BYTES) - .ok_or_else(|| runtime_error(CollabRuntimeFailure::RelayUnavailable))?; - parse_locator_keys(&raw).map_err(|_| runtime_error(CollabRuntimeFailure::RelayUnavailable)) -} - -fn bounded_environment_value(name: &'static str) -> Result { - std::env::var(name) - .ok() - .filter(|value| !value.is_empty() && value.len() <= MAX_RELAY_ENV_BYTES) - .ok_or(CollabRuntimeFailure::RelayUnavailable) -} - #[cfg(any(test, debug_assertions))] fn locator_http_client( - endpoint: &str, - verifier: Ed25519LocatorVerifier, + region: &RelayBootstrapRegion, ) -> Result, CollabRuntimeFailure> { - if let Ok(client) = RelayLocatorHttpClient::new(endpoint, verifier.clone()) { + if let Ok(client) = + RelayLocatorHttpClient::new(®ion.locator_url, region.locator_verifier.clone()) + { return Ok(client); } - let development_http = std::env::var(RELAY_LOCATOR_DEV_HTTP_ENV).ok().as_deref() == Some("1"); - RelayLocatorHttpClient::new_loopback_http_for_development(endpoint, verifier, development_http) - .map_err(|_| CollabRuntimeFailure::RelayUnavailable) + RelayLocatorHttpClient::new_loopback_http_for_development( + ®ion.locator_url, + region.locator_verifier.clone(), + region.development_http, + ) + .map_err(|_| CollabRuntimeFailure::RelayUnavailable) } #[cfg(not(any(test, debug_assertions)))] fn locator_http_client( - endpoint: &str, - verifier: Ed25519LocatorVerifier, + region: &RelayBootstrapRegion, ) -> Result, CollabRuntimeFailure> { - RelayLocatorHttpClient::new(endpoint, verifier) + RelayLocatorHttpClient::new(®ion.locator_url, region.locator_verifier.clone()) .map_err(|_| CollabRuntimeFailure::RelayUnavailable) } -fn parse_locator_keys(raw: &str) -> Result { - let mut keys = HashMap::new(); - for entry in raw.split([',', ';']) { - let entry = entry.trim(); - if entry.is_empty() { - return Err(()); - } - let (key_id, encoded) = entry.split_once('=').ok_or(())?; - LocatorKeyId::new(key_id.to_owned()).map_err(|_| ())?; - if encoded.is_empty() || encoded.contains('=') { - return Err(()); - } - let decoded = URL_SAFE_NO_PAD.decode(encoded).map_err(|_| ())?; - if URL_SAFE_NO_PAD.encode(&decoded) != encoded { - return Err(()); - } - let bytes: [u8; 32] = decoded.try_into().map_err(|_| ())?; - let key = VerifyingKey::from_bytes(&bytes).map_err(|_| ())?; - if keys.insert(key_id.to_owned(), key).is_some() || keys.len() > MAX_RELAY_LOCATOR_KEYS { - return Err(()); - } - } - (!keys.is_empty()) - .then_some(Ed25519LocatorVerifier { keys }) - .ok_or(()) -} - -fn relay_x25519_keys_from_environment( -) -> Result, CollabRuntimeFailure> { - let raw = bounded_environment_value(RELAY_X25519_KEYS_ENV)?; - parse_relay_x25519_keys(&raw).map(std::sync::Arc::new) -} - -fn parse_relay_x25519_keys(raw: &str) -> Result { - let mut keys = Vec::new(); - for entry in raw.split([',', ';']) { - let entry = entry.trim(); - let (key_id, encoded) = entry - .split_once('=') - .filter(|(key_id, encoded)| !key_id.is_empty() && !encoded.is_empty()) - .ok_or(CollabRuntimeFailure::RelayUnavailable)?; - if encoded.contains('=') || keys.len() >= MAX_PINNED_RELAY_X25519_KEYS { - return Err(CollabRuntimeFailure::RelayUnavailable); - } - let key_id = RelayChallengeKeyId::new(key_id.to_owned()) - .map_err(|_| CollabRuntimeFailure::RelayUnavailable)?; - let decoded = URL_SAFE_NO_PAD - .decode(encoded) - .map_err(|_| CollabRuntimeFailure::RelayUnavailable)?; - if URL_SAFE_NO_PAD.encode(&decoded) != encoded { - return Err(CollabRuntimeFailure::RelayUnavailable); - } - let public_key: [u8; 32] = decoded - .try_into() - .map_err(|_| CollabRuntimeFailure::RelayUnavailable)?; - keys.push( - RelayServerX25519PublicKey::new(key_id, public_key) - .map_err(|_| CollabRuntimeFailure::RelayUnavailable)?, - ); - } - PinnedRelayX25519Keys::new(keys).map_err(|_| CollabRuntimeFailure::RelayUnavailable) -} - #[derive(Clone, Copy)] struct AcceptAllDevelopmentLocator; @@ -591,22 +466,16 @@ fn development_unsigned_opt_in( } fn owner_route( - home_region: RelayRegion, owner_static: [u8; 32], discovery_id: String, epoch: Epoch, development_unsigned: bool, control_plane: &dyn RelayLocatorControlPlane, + region: &RelayBootstrapRegion, local: &std::sync::RwLock, ) -> Result { if !development_unsigned { - return publish_production_route( - home_region, - owner_static, - discovery_id, - control_plane, - local, - ); + return publish_production_route(owner_static, discovery_id, control_plane, region, local); } let now = unix_time_ms().map_err(|_| CollabRuntimeFailure::RelayUnavailable)? / 1_000; let not_before = now.saturating_sub(1).max(1); @@ -616,7 +485,7 @@ fn owner_route( let key_id = LocatorKeyId::new(DEBUG_LOCATOR_KEY_ID.to_owned()) .map_err(|_| CollabRuntimeFailure::RelayUnavailable)?; let claims = UnsignedRelayLocatorV1::new( - home_region, + region.region, RouteId::generate().map_err(|_| CollabRuntimeFailure::RelayUnavailable)?, NonZeroU64::new(epoch.0).unwrap_or(NonZeroU64::MIN), OwnerNoiseStatic::new(owner_static).map_err(|_| CollabRuntimeFailure::RelayUnavailable)?, @@ -640,14 +509,14 @@ fn owner_route( } fn publish_production_route( - home_region: RelayRegion, owner_static: [u8; 32], discovery_id: String, control_plane: &dyn RelayLocatorControlPlane, + region: &RelayBootstrapRegion, local: &std::sync::RwLock, ) -> Result { let draft = OwnerPublishDraft::generate( - home_region, + region.region, OwnerNoiseStatic::new(owner_static).map_err(|_| CollabRuntimeFailure::RelayUnavailable)?, ExpectedDiscoveryId::new(discovery_id) .map_err(|_| CollabRuntimeFailure::RelayUnavailable)?, @@ -658,7 +527,7 @@ fn publish_production_route( let local = local .read() .map_err(|_| CollabRuntimeFailure::RelayUnavailable)?; - control_plane.publish_route(draft, local.relay_ticket()) + control_plane.publish_route(draft, local.relay_ticket(), region) } fn random_relay_discovery_id() -> Result { diff --git a/crates/op-host-desktop/src/collab_runtime/relay_bootstrap.rs b/crates/op-host-desktop/src/collab_runtime/relay_bootstrap.rs new file mode 100644 index 000000000..f74ed1b33 --- /dev/null +++ b/crates/op-host-desktop/src/collab_runtime/relay_bootstrap.rs @@ -0,0 +1,795 @@ +use std::collections::{HashMap, HashSet}; +use std::io::Read as _; +use std::net::IpAddr; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex, OnceLock}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use base64::Engine as _; +use ed25519_dalek::{Signature, VerifyingKey}; +use op_collab_relay_client::{ + PinnedRelayX25519Keys, RelayEndpoint, RelayServerX25519PublicKey, MAX_PINNED_RELAY_X25519_KEYS, +}; +use op_collab_relay_protocol::{ + LocatorKeyId, RelayChallengeKeyId, RelayLocatorVerifier, RelayRegion, +}; +use op_collab_transport::DeviceStaticKey; +use reqwest::blocking::Client; +use reqwest::header::{HeaderMap, HeaderValue, ACCEPT, CONTENT_TYPE, ETAG, IF_NONE_MATCH}; +use reqwest::redirect::Policy; +use reqwest::{StatusCode, Url}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest as _, Sha256}; + +use super::types::{CollabRuntimeError, CollabRuntimeFailure}; + +#[path = "relay_bootstrap_url.rs"] +mod bootstrap_url; + +pub(super) const BOOTSTRAP_URL_ENV: &str = "OPENPENCIL_COLLAB_BOOTSTRAP_URL"; +#[cfg(any(test, debug_assertions))] +const BOOTSTRAP_DEV_HTTP_ENV: &str = "OPENPENCIL_COLLAB_BOOTSTRAP_DEV_HTTP"; +#[cfg(any(test, debug_assertions))] +const BOOTSTRAP_DEV_ROOT_KEYS_ENV: &str = "OPENPENCIL_COLLAB_BOOTSTRAP_DEV_ROOT_KEYS"; + +const BOOTSTRAP_PATH: &str = "/api/v1/collaboration/bootstrap"; +const BOOTSTRAP_CONTEXT: &[u8] = b"openpencil/op-hub/collaboration-bootstrap/v1\0"; +const BOOTSTRAP_CACHE_FILE: &str = "collaboration-bootstrap-v1.json"; +const BOOTSTRAP_CONTENT_TYPE: &str = "application/json"; +const BOOTSTRAP_VERSION: u64 = 1; +const BUILTIN_ROOT_KID: &str = "openpencil-collab-root-v1"; +const BUILTIN_ROOT_X: &str = "wiQJcA9o-bydBkfIVnVUJzKA4wtv8Dapn0JYhS_bZ-I"; +const MAX_RESPONSE_BYTES: usize = 64 * 1024; +const MAX_PAYLOAD_BYTES: usize = 32 * 1024; +const MAX_CACHE_BYTES: u64 = (MAX_RESPONSE_BYTES as u64 * 2) + 4_096; +#[cfg(any(test, debug_assertions))] +const MAX_ENV_BYTES: usize = 8 * 1024; +const MAX_ETAG_BYTES: usize = 256; +#[cfg(any(test, debug_assertions))] +const MAX_ROOT_KEYS: usize = 8; +const MAX_REGION_KEYS: usize = 8; +const MAX_KEY_ID_BYTES: usize = 64; +const MAX_RELAY_KEY_ID_BYTES: usize = 30; +const MAX_URL_BYTES: usize = 2_048; +const MAX_CLOCK_SKEW_SECS: u64 = 300; +const MAX_VALIDITY_SECS: u64 = 7 * 24 * 60 * 60; +const MAX_SAFE_INTEGER: u64 = (1 << 53) - 1; +const MAX_UNIX_SECOND: u64 = 253_402_300_799; +const CONNECT_TIMEOUT: Duration = Duration::from_secs(5); +const REQUEST_TIMEOUT: Duration = Duration::from_secs(10); + +static CACHE_LOCK: OnceLock> = OnceLock::new(); + +pub(super) trait RelayBootstrapProvider: Send + Sync { + fn load(&self) -> Result, CollabRuntimeFailure>; +} + +#[derive(Debug)] +pub(super) struct RelayBootstrap { + generation: u64, + signed_payload: Box<[u8]>, + regions: Vec, +} + +impl RelayBootstrap { + pub(super) fn region( + &self, + region: RelayRegion, + ) -> Result<&RelayBootstrapRegion, CollabRuntimeFailure> { + self.regions + .iter() + .find(|candidate| candidate.region == region) + .ok_or(CollabRuntimeFailure::RelayRegionUnavailable) + } +} + +#[derive(Debug)] +pub(crate) struct RelayBootstrapRegion { + pub(super) region: RelayRegion, + pub(super) relay_endpoint: RelayEndpoint, + pub(super) locator_url: String, + pub(super) locator_verifier: Ed25519LocatorVerifier, + pub(super) relay_x25519_keys: Arc, + #[cfg(any(test, debug_assertions))] + pub(super) development_http: bool, +} + +#[derive(Clone, Debug)] +pub(super) struct Ed25519LocatorVerifier { + keys: HashMap, +} + +impl RelayLocatorVerifier for Ed25519LocatorVerifier { + fn verify( + &self, + key_id: &LocatorKeyId, + canonical_signing_bytes: &[u8], + signature: &[u8; 64], + ) -> bool { + self.keys.get(key_id.as_str()).is_some_and(|key| { + key.verify_strict(canonical_signing_bytes, &Signature::from_bytes(signature)) + .is_ok() + }) + } +} + +pub(super) fn provider_from_environment( +) -> Result>, CollabRuntimeError> { + let Some(raw) = std::env::var_os(BOOTSTRAP_URL_ENV) else { + return Ok(None); + }; + let raw = raw + .to_str() + .filter(|value| !value.is_empty() && value.len() <= MAX_URL_BYTES) + .ok_or_else(relay_runtime_error)?; + let provider = + EnvironmentRelayBootstrapProvider::new(raw).map_err(|_| relay_runtime_error())?; + Ok(Some(Arc::new(provider))) +} + +struct EnvironmentRelayBootstrapProvider { + endpoint: Url, + roots: HashMap, + development_http: bool, + cache_path: PathBuf, +} + +impl EnvironmentRelayBootstrapProvider { + fn new(endpoint: &str) -> Result { + let (endpoint, development_http) = parse_bootstrap_endpoint(endpoint)?; + let mut roots = builtin_roots()?; + if development_http { + add_development_roots(&mut roots)?; + } + let cache_path = op_config_store::ConfigStore::user() + .and_then(|store| store.path(BOOTSTRAP_CACHE_FILE)) + .map_err(|_| BootstrapError::Cache)?; + Ok(Self { + endpoint, + roots, + development_http, + cache_path, + }) + } + + fn load_inner(&self, now: u64) -> Result, BootstrapError> { + let _guard = CACHE_LOCK + .get_or_init(|| Mutex::new(())) + .lock() + .map_err(|_| BootstrapError::Cache)?; + let cached = read_cache(&self.cache_path, self.endpoint.as_str()) + .ok() + .flatten(); + let cached_verified = cached.as_ref().and_then(|cached| { + verify_bootstrap( + cached.body.as_bytes(), + &self.roots, + now, + self.development_http, + true, + ) + .ok() + }); + let cached_signed = cached.as_ref().and_then(|cached| { + verify_bootstrap( + cached.body.as_bytes(), + &self.roots, + now, + self.development_http, + false, + ) + .ok() + }); + let response = match self.fetch(cached.as_ref()) { + Ok(response) => response, + Err(_) => { + return cached_verified + .map(Arc::new) + .ok_or(BootstrapError::Transport) + } + }; + if response.status() == StatusCode::NOT_MODIFIED { + validate_not_modified(response.headers(), cached.as_ref())?; + return cached_verified + .map(Arc::new) + .ok_or(BootstrapError::InvalidResponse); + } + if response.status() != StatusCode::OK { + return cached_verified + .map(Arc::new) + .ok_or(BootstrapError::Transport); + } + validate_content_type(response.headers())?; + if response + .content_length() + .is_some_and(|length| length > MAX_RESPONSE_BYTES as u64) + { + return Err(BootstrapError::ResponseTooLarge); + } + let response_headers = response.headers().clone(); + let mut body = Vec::with_capacity(MAX_RESPONSE_BYTES); + response + .take((MAX_RESPONSE_BYTES + 1) as u64) + .read_to_end(&mut body) + .map_err(|_| BootstrapError::Transport)?; + if body.len() > MAX_RESPONSE_BYTES { + return Err(BootstrapError::ResponseTooLarge); + } + let etag = Some(response_etag(&response_headers, &body)?); + let verified = verify_bootstrap(&body, &self.roots, now, self.development_http, true)?; + if let Some(previous) = cached_signed.as_ref() { + reject_rollback(previous, &verified)?; + } + if let Ok(body) = String::from_utf8(body) { + let _ = write_cache( + &self.cache_path, + &BootstrapCache { + endpoint: self.endpoint.as_str().to_owned(), + etag, + body, + }, + ); + } + Ok(Arc::new(verified)) + } + + fn fetch( + &self, + cached: Option<&BootstrapCache>, + ) -> Result { + let client = Client::builder() + .redirect(Policy::none()) + .no_proxy() + .connect_timeout(CONNECT_TIMEOUT) + .timeout(REQUEST_TIMEOUT) + .https_only(!self.development_http) + .build() + .map_err(|_| BootstrapError::Transport)?; + let mut request = client + .get(self.endpoint.clone()) + .header(ACCEPT, BOOTSTRAP_CONTENT_TYPE); + if let Some(etag) = cached.and_then(|cached| cached.etag.as_deref()) { + let value = HeaderValue::from_str(etag).map_err(|_| BootstrapError::Cache)?; + request = request.header(IF_NONE_MATCH, value); + } + request.send().map_err(|_| BootstrapError::Transport) + } +} + +impl RelayBootstrapProvider for EnvironmentRelayBootstrapProvider { + fn load(&self) -> Result, CollabRuntimeFailure> { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|_| CollabRuntimeFailure::RelayUnavailable)? + .as_secs(); + self.load_inner(now) + .map_err(|_| CollabRuntimeFailure::RelayUnavailable) + } +} + +fn parse_bootstrap_endpoint(value: &str) -> Result<(Url, bool), BootstrapError> { + parse_bootstrap_endpoint_with_policy(value, development_http_enabled()) +} + +fn parse_bootstrap_endpoint_with_policy( + value: &str, + allow_development_http: bool, +) -> Result<(Url, bool), BootstrapError> { + let endpoint = + bootstrap_url::parse(value, BOOTSTRAP_PATH).ok_or(BootstrapError::InvalidEndpoint)?; + if endpoint.scheme() == "https" { + return Ok((endpoint, false)); + } + if allow_development_http + && endpoint.scheme() == "http" + && endpoint_has_numeric_loopback(&endpoint) + { + return Ok((endpoint, true)); + } + Err(BootstrapError::InvalidEndpoint) +} + +fn verify_bootstrap( + body: &[u8], + roots: &HashMap, + now: u64, + development_http: bool, + require_current: bool, +) -> Result { + if body.is_empty() || body.len() > MAX_RESPONSE_BYTES { + return Err(BootstrapError::InvalidResponse); + } + let envelope: BootstrapEnvelope = + serde_json::from_slice(body).map_err(|_| BootstrapError::InvalidResponse)?; + if serde_json::to_vec(&envelope).map_err(|_| BootstrapError::InvalidResponse)? != body { + return Err(BootstrapError::InvalidResponse); + } + if envelope.version != BOOTSTRAP_VERSION || !valid_key_id(&envelope.kid) { + return Err(BootstrapError::InvalidResponse); + } + let root = roots + .get(&envelope.kid) + .ok_or(BootstrapError::UnknownRoot)?; + let signature = decode_fixed::<64>(&envelope.signature)?; + let payload_bytes = decode_bounded(&envelope.payload, MAX_PAYLOAD_BYTES)?; + let mut signing_bytes = Vec::with_capacity(BOOTSTRAP_CONTEXT.len() + payload_bytes.len()); + signing_bytes.extend_from_slice(BOOTSTRAP_CONTEXT); + signing_bytes.extend_from_slice(&payload_bytes); + root.verify_strict(&signing_bytes, &Signature::from_bytes(&signature)) + .map_err(|_| BootstrapError::InvalidSignature)?; + let payload: BootstrapPayload = + serde_json::from_slice(&payload_bytes).map_err(|_| BootstrapError::InvalidPayload)?; + if serde_json::to_vec(&payload).map_err(|_| BootstrapError::InvalidPayload)? != payload_bytes { + return Err(BootstrapError::InvalidPayload); + } + validate_payload(&payload, now, require_current)?; + let mut seen_regions = HashSet::new(); + let mut regions = Vec::with_capacity(payload.regions.len()); + for wire in payload.regions { + let region = parse_region(&wire.region)?; + if !seen_regions.insert(region) { + return Err(BootstrapError::InvalidPayload); + } + regions.push(validate_region(wire, region, development_http)?); + } + Ok(RelayBootstrap { + generation: payload.generation, + signed_payload: payload_bytes.into_boxed_slice(), + regions, + }) +} + +fn validate_payload( + payload: &BootstrapPayload, + now: u64, + require_current: bool, +) -> Result<(), BootstrapError> { + if payload.version != BOOTSTRAP_VERSION + || payload.generation == 0 + || payload.generation > MAX_SAFE_INTEGER + || payload.regions.len() != 2 + || payload.regions[0].region != "cn" + || payload.regions[1].region != "global" + || payload.not_before_unix == 0 + || payload.not_before_unix > MAX_UNIX_SECOND + || payload.not_after_unix > MAX_UNIX_SECOND + || payload.not_after_unix <= payload.not_before_unix + || payload.not_after_unix - payload.not_before_unix > MAX_VALIDITY_SECS + || payload.regions[0].relay_url == payload.regions[1].relay_url + || payload.regions[0].locator_url == payload.regions[1].locator_url + { + return Err(BootstrapError::InvalidPayload); + } + validate_global_key_registry(&payload.regions, |region| ®ion.locator_keys)?; + validate_global_key_registry(&payload.regions, |region| ®ion.relay_x25519_keys)?; + if require_current + && (payload.not_before_unix > now.saturating_add(MAX_CLOCK_SKEW_SECS) + || payload.not_after_unix <= now) + { + return Err(BootstrapError::Expired); + } + Ok(()) +} + +fn validate_region( + wire: BootstrapRegion, + region: RelayRegion, + development_http: bool, +) -> Result { + let parsed_relay = bootstrap_url::parse(&wire.relay_url, "/v1/tunnel") + .ok_or(BootstrapError::InvalidPayload)?; + let relay_endpoint = + RelayEndpoint::parse(&wire.relay_url).map_err(|_| BootstrapError::InvalidPayload)?; + if parsed_relay.scheme() != "wss" + && !(development_http + && parsed_relay.scheme() == "ws" + && endpoint_has_numeric_loopback(&parsed_relay)) + { + return Err(BootstrapError::InvalidPayload); + } + validate_locator_url(&wire.locator_url, development_http)?; + let locator_verifier = locator_verifier(wire.locator_keys)?; + let relay_x25519_keys = Arc::new(relay_x25519_keys(wire.relay_x25519_keys)?); + Ok(RelayBootstrapRegion { + region, + relay_endpoint, + locator_url: wire.locator_url, + locator_verifier, + relay_x25519_keys, + #[cfg(any(test, debug_assertions))] + development_http, + }) +} + +fn locator_verifier(keys: Vec) -> Result { + if keys.is_empty() || keys.len() > MAX_REGION_KEYS || !strictly_sorted_kids(&keys) { + return Err(BootstrapError::InvalidPayload); + } + let mut parsed = HashMap::with_capacity(keys.len()); + for key in keys { + if !valid_key_id(&key.kid) { + return Err(BootstrapError::InvalidPayload); + } + LocatorKeyId::new(key.kid.clone()).map_err(|_| BootstrapError::InvalidPayload)?; + let bytes = decode_fixed::<32>(&key.x)?; + let key_value = canonical_ed25519_key(bytes, BootstrapError::InvalidPayload)?; + if parsed.insert(key.kid, key_value).is_some() { + return Err(BootstrapError::InvalidPayload); + } + } + Ok(Ed25519LocatorVerifier { keys: parsed }) +} + +fn canonical_ed25519_key( + bytes: [u8; 32], + error: BootstrapError, +) -> Result { + let mut y = bytes; + y[31] &= 0x7f; + if !canonical_curve25519_field_element(&y) { + return Err(error); + } + let key = VerifyingKey::from_bytes(&bytes).map_err(|_| error)?; + (!key.is_weak()).then_some(key).ok_or(error) +} + +fn relay_x25519_keys(keys: Vec) -> Result { + if keys.is_empty() + || keys.len() > MAX_REGION_KEYS + || keys.len() > MAX_PINNED_RELAY_X25519_KEYS + || !strictly_sorted_kids(&keys) + { + return Err(BootstrapError::InvalidPayload); + } + let mut seen = HashSet::with_capacity(keys.len()); + let mut parsed = Vec::with_capacity(keys.len()); + for key in keys { + if !valid_relay_key_id(&key.kid) || !seen.insert(key.kid.clone()) { + return Err(BootstrapError::InvalidPayload); + } + let kid = RelayChallengeKeyId::new(key.kid).map_err(|_| BootstrapError::InvalidPayload)?; + let public_key = decode_fixed::<32>(&key.x)?; + if !canonical_curve25519_field_element(&public_key) { + return Err(BootstrapError::InvalidPayload); + } + let probe = DeviceStaticKey::from_private([0x42; 32]) + .map_err(|_| BootstrapError::InvalidPayload)?; + probe + .agree_x25519(&public_key) + .map_err(|_| BootstrapError::InvalidPayload)?; + parsed.push( + RelayServerX25519PublicKey::new(kid, public_key) + .map_err(|_| BootstrapError::InvalidPayload)?, + ); + } + PinnedRelayX25519Keys::new(parsed).map_err(|_| BootstrapError::InvalidPayload) +} + +fn strictly_sorted_kids(keys: &[BootstrapKey]) -> bool { + keys.windows(2) + .all(|pair| pair[0].kid.as_bytes() < pair[1].kid.as_bytes()) +} + +fn validate_global_key_registry( + regions: &[BootstrapRegion], + select: fn(&BootstrapRegion) -> &[BootstrapKey], +) -> Result<(), BootstrapError> { + let mut by_kid = HashMap::<&str, &str>::new(); + let mut by_x = HashMap::<&str, &str>::new(); + for key in regions.iter().flat_map(select) { + if by_kid + .insert(key.kid.as_str(), key.x.as_str()) + .is_some_and(|existing| existing != key.x) + || by_x + .insert(key.x.as_str(), key.kid.as_str()) + .is_some_and(|existing| existing != key.kid) + { + return Err(BootstrapError::InvalidPayload); + } + } + Ok(()) +} + +fn canonical_curve25519_field_element(public_key: &[u8; 32]) -> bool { + // Both Edwards and Montgomery decoders reduce/mask for compatibility. + // Signed bootstrap pins require the unique encoding below 2^255 - 19. + const FIELD_MODULUS: [u8; 32] = [ + 0xed, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0x7f, + ]; + for index in (0..public_key.len()).rev() { + match public_key[index].cmp(&FIELD_MODULUS[index]) { + std::cmp::Ordering::Less => return true, + std::cmp::Ordering::Greater => return false, + std::cmp::Ordering::Equal => {} + } + } + false +} + +fn validate_locator_url(value: &str, development_http: bool) -> Result<(), BootstrapError> { + let endpoint = bootstrap_url::parse( + value, + op_collab_relay_control_plane::RELAY_LOCATOR_PUBLISH_PATH, + ) + .ok_or(BootstrapError::InvalidPayload)?; + let secure = endpoint.scheme() == "https"; + let development = + development_http && endpoint.scheme() == "http" && endpoint_has_numeric_loopback(&endpoint); + (secure || development) + .then_some(()) + .ok_or(BootstrapError::InvalidPayload) +} + +fn parse_region(value: &str) -> Result { + match value { + "cn" => Ok(RelayRegion::Cn), + "global" => Ok(RelayRegion::Global), + _ => Err(BootstrapError::InvalidPayload), + } +} + +fn reject_rollback( + previous: &RelayBootstrap, + current: &RelayBootstrap, +) -> Result<(), BootstrapError> { + if current.generation < previous.generation + || (current.generation == previous.generation + && current.signed_payload != previous.signed_payload) + { + return Err(BootstrapError::Rollback); + } + Ok(()) +} + +fn builtin_roots() -> Result, BootstrapError> { + let bytes = decode_fixed::<32>(BUILTIN_ROOT_X)?; + let key = canonical_ed25519_key(bytes, BootstrapError::InvalidRoot)?; + Ok(HashMap::from([(BUILTIN_ROOT_KID.to_owned(), key)])) +} + +#[cfg(any(test, debug_assertions))] +fn add_development_roots(roots: &mut HashMap) -> Result<(), BootstrapError> { + let Some(raw) = std::env::var(BOOTSTRAP_DEV_ROOT_KEYS_ENV).ok() else { + return Ok(()); + }; + if raw.is_empty() || raw.len() > MAX_ENV_BYTES { + return Err(BootstrapError::InvalidRoot); + } + for entry in raw.split([',', ';']) { + let (kid, encoded) = entry.split_once('=').ok_or(BootstrapError::InvalidRoot)?; + if !valid_key_id(kid) || roots.len() >= MAX_ROOT_KEYS { + return Err(BootstrapError::InvalidRoot); + } + let bytes = decode_fixed::<32>(encoded)?; + let key = canonical_ed25519_key(bytes, BootstrapError::InvalidRoot)?; + if roots.insert(kid.to_owned(), key).is_some() { + return Err(BootstrapError::InvalidRoot); + } + } + Ok(()) +} + +#[cfg(not(any(test, debug_assertions)))] +fn add_development_roots(_roots: &mut HashMap) -> Result<(), BootstrapError> { + Ok(()) +} + +fn decode_fixed(encoded: &str) -> Result<[u8; N], BootstrapError> { + if encoded.is_empty() || encoded.contains('=') { + return Err(BootstrapError::InvalidBase64); + } + let decoded = URL_SAFE_NO_PAD + .decode(encoded) + .map_err(|_| BootstrapError::InvalidBase64)?; + if URL_SAFE_NO_PAD.encode(&decoded) != encoded { + return Err(BootstrapError::InvalidBase64); + } + decoded + .try_into() + .map_err(|_| BootstrapError::InvalidBase64) +} + +fn decode_bounded(encoded: &str, maximum: usize) -> Result, BootstrapError> { + if encoded.is_empty() || encoded.contains('=') || encoded.len() > MAX_RESPONSE_BYTES { + return Err(BootstrapError::InvalidBase64); + } + let decoded = URL_SAFE_NO_PAD + .decode(encoded) + .map_err(|_| BootstrapError::InvalidBase64)?; + if decoded.is_empty() || decoded.len() > maximum || URL_SAFE_NO_PAD.encode(&decoded) != encoded + { + return Err(BootstrapError::InvalidBase64); + } + Ok(decoded) +} + +fn valid_key_id(value: &str) -> bool { + !value.is_empty() + && value.len() <= MAX_KEY_ID_BYTES + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_')) +} + +fn valid_relay_key_id(value: &str) -> bool { + value.len() <= MAX_RELAY_KEY_ID_BYTES && valid_key_id(value) +} + +fn endpoint_has_numeric_loopback(endpoint: &Url) -> bool { + endpoint + .host_str() + .map(|host| host.trim_matches(['[', ']'])) + .and_then(|host| host.parse::().ok()) + .is_some_and(|address| address.is_loopback()) +} + +#[cfg(any(test, debug_assertions))] +fn development_http_enabled() -> bool { + std::env::var(BOOTSTRAP_DEV_HTTP_ENV).ok().as_deref() == Some("1") +} + +#[cfg(not(any(test, debug_assertions)))] +const fn development_http_enabled() -> bool { + false +} + +fn validate_content_type(headers: &HeaderMap) -> Result<(), BootstrapError> { + let value = headers + .get(CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .ok_or(BootstrapError::InvalidResponse)?; + let valid = value.eq_ignore_ascii_case("application/json; charset=utf-8"); + valid.then_some(()).ok_or(BootstrapError::InvalidResponse) +} + +fn response_etag(headers: &HeaderMap, body: &[u8]) -> Result { + let value = headers + .get(ETAG) + .and_then(|value| value.to_str().ok()) + .ok_or(BootstrapError::InvalidResponse)?; + let expected = strong_etag(body); + (value == expected) + .then_some(expected) + .ok_or(BootstrapError::InvalidResponse) +} + +fn validate_not_modified( + headers: &HeaderMap, + cached: Option<&BootstrapCache>, +) -> Result<(), BootstrapError> { + let cached = cached.ok_or(BootstrapError::InvalidResponse)?; + let expected = cached + .etag + .as_deref() + .ok_or(BootstrapError::InvalidResponse)?; + let actual = headers + .get(ETAG) + .and_then(|value| value.to_str().ok()) + .ok_or(BootstrapError::InvalidResponse)?; + (actual == expected) + .then_some(()) + .ok_or(BootstrapError::InvalidResponse) +} + +fn strong_etag(body: &[u8]) -> String { + let digest = Sha256::digest(body); + let mut etag = String::with_capacity(66); + etag.push('"'); + for byte in digest { + use std::fmt::Write as _; + write!(etag, "{byte:02x}").expect("writing to String cannot fail"); + } + etag.push('"'); + etag +} + +#[derive(Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +struct BootstrapEnvelope { + version: u64, + kid: String, + payload: String, + signature: String, +} + +#[derive(Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +struct BootstrapPayload { + version: u64, + generation: u64, + not_before_unix: u64, + not_after_unix: u64, + regions: Vec, +} + +#[derive(Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +struct BootstrapRegion { + region: String, + relay_url: String, + locator_url: String, + locator_keys: Vec, + relay_x25519_keys: Vec, +} + +#[derive(Clone, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +struct BootstrapKey { + kid: String, + x: String, +} + +#[derive(Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +struct BootstrapCache { + endpoint: String, + etag: Option, + body: String, +} + +fn read_cache(path: &Path, endpoint: &str) -> Result, BootstrapError> { + let metadata = match std::fs::metadata(path) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(_) => return Err(BootstrapError::Cache), + }; + if !metadata.is_file() || metadata.len() > MAX_CACHE_BYTES { + return Err(BootstrapError::Cache); + } + let bytes = std::fs::read(path).map_err(|_| BootstrapError::Cache)?; + let cache: BootstrapCache = + serde_json::from_slice(&bytes).map_err(|_| BootstrapError::Cache)?; + if cache.endpoint != endpoint + || cache.body.is_empty() + || cache.body.len() > MAX_RESPONSE_BYTES + || cache + .etag + .as_ref() + .is_some_and(|etag| etag.is_empty() || etag.len() > MAX_ETAG_BYTES) + { + return Err(BootstrapError::Cache); + } + let expected_etag = strong_etag(cache.body.as_bytes()); + if cache.etag.as_deref() != Some(expected_etag.as_str()) { + return Err(BootstrapError::Cache); + } + Ok(Some(cache)) +} + +fn write_cache(path: &Path, cache: &BootstrapCache) -> Result<(), BootstrapError> { + let parent = path.parent().ok_or(BootstrapError::Cache)?; + let file = path + .file_name() + .and_then(|value| value.to_str()) + .ok_or(BootstrapError::Cache)?; + op_config_store::ConfigStore::at(parent) + .write_json(file, cache) + .map_err(|_| BootstrapError::Cache) +} + +fn relay_runtime_error() -> CollabRuntimeError { + CollabRuntimeError::new(CollabRuntimeFailure::RelayUnavailable) +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum BootstrapError { + Cache, + Expired, + InvalidBase64, + InvalidEndpoint, + InvalidPayload, + InvalidResponse, + InvalidRoot, + InvalidSignature, + ResponseTooLarge, + Rollback, + Transport, + UnknownRoot, +} + +#[cfg(test)] +#[path = "relay_bootstrap_tests.rs"] +mod tests; diff --git a/crates/op-host-desktop/src/collab_runtime/relay_bootstrap_testdata/golden-envelope.json b/crates/op-host-desktop/src/collab_runtime/relay_bootstrap_testdata/golden-envelope.json new file mode 100644 index 000000000..2272cf01d --- /dev/null +++ b/crates/op-host-desktop/src/collab_runtime/relay_bootstrap_testdata/golden-envelope.json @@ -0,0 +1 @@ +{"version":1,"kid":"test-collab-root-v1","payload":"eyJ2ZXJzaW9uIjoxLCJnZW5lcmF0aW9uIjo0Miwibm90X2JlZm9yZV91bml4IjoxODkzNDU2MDAwLCJub3RfYWZ0ZXJfdW5peCI6MTg5NDA2MDgwMCwicmVnaW9ucyI6W3sicmVnaW9uIjoiY24iLCJyZWxheV91cmwiOiJ3c3M6Ly9yZWxheS5leGFtcGxlLmNuL3YxL3R1bm5lbCIsImxvY2F0b3JfdXJsIjoiaHR0cHM6Ly9sb2NhdG9yLmV4YW1wbGUuY24vdjEvbG9jYXRvciIsImxvY2F0b3Jfa2V5cyI6W3sia2lkIjoibG9jYXRvci1jbi0yMDMwLTAxIiwieCI6IktheTY0VUc4eXZDeUxocVUwMDBMeHpZZVVtMExfaExJbDVTOGt5S1diZGMifV0sInJlbGF5X3gyNTUxOV9rZXlzIjpbeyJraWQiOiJyZWxheS1jbi0yMDMwLTAxIiwieCI6IloxM1ZkTzEzaVRFTFBTNTJnZk41QzBac2R6c1ZJZjdQTmxkNVdEY2VwUzgifV19LHsicmVnaW9uIjoiZ2xvYmFsIiwicmVsYXlfdXJsIjoid3NzOi8vcmVsYXkuZXhhbXBsZS5jb20vdjEvdHVubmVsIiwibG9jYXRvcl91cmwiOiJodHRwczovL2xvY2F0b3IuZXhhbXBsZS5jb20vdjEvbG9jYXRvciIsImxvY2F0b3Jfa2V5cyI6W3sia2lkIjoibG9jYXRvci1nbG9iYWwtMjAzMC0wMSIsIngiOiJKVU81TF9FSlZSRkhhdHlEYWR0dDNKTTJaYUVaZU4yaFFFN2hCbXlwVlowIn1dLCJyZWxheV94MjU1MTlfa2V5cyI6W3sia2lkIjoicmVsYXktZ2xvYmFsLTIwMzAtMDEiLCJ4IjoiU1Q2Q19IUkdTbGttaUJkaVBTQlR4ZXVPTE1TcGlMVC00WG5zYXdFTlV4MCJ9XX1dfQ","signature":"zxYo5RkaGCxa5Otax4F4p_EU6zsPG4KvkrE8L-O_HHVRqXy4tgSt5GJW0UPjpJMYDGxEvPgXYaBtEPnxzA_MCg"} diff --git a/crates/op-host-desktop/src/collab_runtime/relay_bootstrap_testdata/golden-payload.json b/crates/op-host-desktop/src/collab_runtime/relay_bootstrap_testdata/golden-payload.json new file mode 100644 index 000000000..706776d4f --- /dev/null +++ b/crates/op-host-desktop/src/collab_runtime/relay_bootstrap_testdata/golden-payload.json @@ -0,0 +1 @@ +{"version":1,"generation":42,"not_before_unix":1893456000,"not_after_unix":1894060800,"regions":[{"region":"cn","relay_url":"wss://relay.example.cn/v1/tunnel","locator_url":"https://locator.example.cn/v1/locator","locator_keys":[{"kid":"locator-cn-2030-01","x":"Kay64UG8yvCyLhqU000LxzYeUm0L_hLIl5S8kyKWbdc"}],"relay_x25519_keys":[{"kid":"relay-cn-2030-01","x":"Z13VdO13iTELPS52gfN5C0ZsdzsVIf7PNld5WDcepS8"}]},{"region":"global","relay_url":"wss://relay.example.com/v1/tunnel","locator_url":"https://locator.example.com/v1/locator","locator_keys":[{"kid":"locator-global-2030-01","x":"JUO5L_EJVRFHatyDadtt3JM2ZaEZeN2hQE7hBmypVZ0"}],"relay_x25519_keys":[{"kid":"relay-global-2030-01","x":"ST6C_HRGSlkmiBdiPSBTxeuOLMSpiLT-4XnsawENUx0"}]}]} diff --git a/crates/op-host-desktop/src/collab_runtime/relay_bootstrap_tests.rs b/crates/op-host-desktop/src/collab_runtime/relay_bootstrap_tests.rs new file mode 100644 index 000000000..5ef3c87f5 --- /dev/null +++ b/crates/op-host-desktop/src/collab_runtime/relay_bootstrap_tests.rs @@ -0,0 +1,597 @@ +use std::io::{Read as _, Write as _}; +use std::net::{Ipv4Addr, TcpListener}; +use std::sync::mpsc; +use std::thread; + +use ed25519_dalek::{Signer as _, SigningKey}; + +use super::*; + +const NOW: u64 = 1_900_000_000; + +#[test] +fn go_signer_golden_envelope_verifies_byte_exactly() { + const ENVELOPE: &str = include_str!("relay_bootstrap_testdata/golden-envelope.json"); + const PAYLOAD: &str = include_str!("relay_bootstrap_testdata/golden-payload.json"); + const GOLDEN_ROOT_X: &str = "A6EHv_POEL4dcN0Y50vAmWfk1jCbpQ1fHdyGZBJVMbg"; + let root = VerifyingKey::from_bytes(&decode_fixed::<32>(GOLDEN_ROOT_X).unwrap()).unwrap(); + let roots = HashMap::from([("test-collab-root-v1".to_owned(), root)]); + let envelope = ENVELOPE.trim_end_matches('\n'); + let verified = + verify_bootstrap(envelope.as_bytes(), &roots, 1_893_456_060, false, true).unwrap(); + let wire: BootstrapEnvelope = serde_json::from_str(envelope).unwrap(); + assert_eq!( + decode_bounded(&wire.payload, MAX_PAYLOAD_BYTES).unwrap(), + PAYLOAD.trim_end_matches('\n').as_bytes() + ); + assert_eq!(verified.generation, 42); + assert!(verified.region(RelayRegion::Cn).is_ok()); + assert!(verified.region(RelayRegion::Global).is_ok()); +} + +fn key(kid: &str, bytes: [u8; 32]) -> BootstrapKey { + BootstrapKey { + kid: kid.to_owned(), + x: URL_SAFE_NO_PAD.encode(bytes), + } +} + +fn noncanonical_ed25519_encoding() -> [u8; 32] { + for y in 2_u8..=18 { + for sign in [0_u8, 0x80] { + let mut encoded = [0xff; 32]; + encoded[0] = 0xed + y; + encoded[31] = 0x7f | sign; + if VerifyingKey::from_bytes(&encoded).is_ok_and(|key| !key.is_weak()) { + return encoded; + } + } + } + panic!("dalek accepted no non-canonical non-weak Ed25519 encoding") +} + +fn valid_payload() -> BootstrapPayload { + let locator_cn = SigningKey::from_bytes(&[11; 32]); + let locator_global = SigningKey::from_bytes(&[12; 32]); + let relay_cn = DeviceStaticKey::from_private([21; 32]).unwrap(); + let relay_global = DeviceStaticKey::from_private([22; 32]).unwrap(); + BootstrapPayload { + version: 1, + generation: 7, + not_before_unix: NOW - 60, + not_after_unix: NOW + 3_600, + regions: vec![ + BootstrapRegion { + region: "cn".to_owned(), + relay_url: "wss://relay-cn.example/v1/tunnel".to_owned(), + locator_url: "https://locator-cn.example/v1/locator".to_owned(), + locator_keys: vec![key("locator_cn_1", *locator_cn.verifying_key().as_bytes())], + relay_x25519_keys: vec![key("relay_cn_1", *relay_cn.public_key())], + }, + BootstrapRegion { + region: "global".to_owned(), + relay_url: "wss://relay-global.example/v1/tunnel".to_owned(), + locator_url: "https://locator-global.example/v1/locator".to_owned(), + locator_keys: vec![key( + "locator_global_1", + *locator_global.verifying_key().as_bytes(), + )], + relay_x25519_keys: vec![key("relay_global_1", *relay_global.public_key())], + }, + ], + } +} + +fn signed_envelope(signing: &SigningKey, kid: &str, payload: &BootstrapPayload) -> Vec { + let payload = serde_json::to_vec(payload).unwrap(); + let mut signing_bytes = BOOTSTRAP_CONTEXT.to_vec(); + signing_bytes.extend_from_slice(&payload); + let envelope = BootstrapEnvelope { + version: 1, + kid: kid.to_owned(), + payload: URL_SAFE_NO_PAD.encode(payload), + signature: URL_SAFE_NO_PAD.encode(signing.sign(&signing_bytes).to_bytes()), + }; + serde_json::to_vec(&envelope).unwrap() +} + +fn roots(signing: &SigningKey, kid: &str) -> HashMap { + HashMap::from([(kid.to_owned(), signing.verifying_key())]) +} + +#[test] +fn signed_canonical_bundle_builds_both_region_snapshots() { + assert!(builtin_roots().is_ok()); + let signing = SigningKey::from_bytes(&[7; 32]); + let body = signed_envelope(&signing, "test_root_1", &valid_payload()); + let bootstrap = + verify_bootstrap(&body, &roots(&signing, "test_root_1"), NOW, false, true).unwrap(); + + assert_eq!(bootstrap.generation, 7); + let cn = bootstrap.region(RelayRegion::Cn).unwrap(); + let global = bootstrap.region(RelayRegion::Global).unwrap(); + assert_eq!( + cn.relay_endpoint, + RelayEndpoint::parse("wss://relay-cn.example/v1/tunnel").unwrap() + ); + assert_eq!( + global.relay_endpoint, + RelayEndpoint::parse("wss://relay-global.example/v1/tunnel").unwrap() + ); + assert_eq!(cn.locator_url, "https://locator-cn.example/v1/locator"); + assert!(!cn.development_http); +} + +#[test] +fn signature_payload_and_envelope_are_strictly_canonical() { + let signing = SigningKey::from_bytes(&[7; 32]); + let roots = roots(&signing, "test_root_1"); + let body = signed_envelope(&signing, "test_root_1", &valid_payload()); + + let mut tampered = body.clone(); + let position = tampered + .iter() + .position(|byte| *byte == b'A') + .unwrap_or(tampered.len() / 2); + tampered[position] ^= 1; + assert!(verify_bootstrap(&tampered, &roots, NOW, false, true).is_err()); + + let envelope: BootstrapEnvelope = serde_json::from_slice(&body).unwrap(); + let padded_payload = format!("{}=", envelope.payload); + let noncanonical = serde_json::to_vec(&BootstrapEnvelope { + payload: padded_payload, + ..envelope + }) + .unwrap(); + assert_eq!( + verify_bootstrap(&noncanonical, &roots, NOW, false, true).unwrap_err(), + BootstrapError::InvalidBase64 + ); + + let spaced = format!(" {}", String::from_utf8(body).unwrap()); + assert_eq!( + verify_bootstrap(spaced.as_bytes(), &roots, NOW, false, true).unwrap_err(), + BootstrapError::InvalidResponse + ); +} + +#[test] +fn payload_rejects_noncanonical_json_duplicate_regions_keys_and_unknown_fields() { + let signing = SigningKey::from_bytes(&[7; 32]); + let roots = roots(&signing, "test_root_1"); + + let mut payload = valid_payload(); + payload.regions[1].region = "cn".to_owned(); + let body = signed_envelope(&signing, "test_root_1", &payload); + assert_eq!( + verify_bootstrap(&body, &roots, NOW, false, true).unwrap_err(), + BootstrapError::InvalidPayload + ); + + let mut payload = valid_payload(); + payload.regions.reverse(); + let body = signed_envelope(&signing, "test_root_1", &payload); + assert_eq!( + verify_bootstrap(&body, &roots, NOW, false, true).unwrap_err(), + BootstrapError::InvalidPayload + ); + + let mut payload = valid_payload(); + let duplicate = payload.regions[0].locator_keys[0].clone(); + payload.regions[0].locator_keys.push(duplicate); + let body = signed_envelope(&signing, "test_root_1", &payload); + assert_eq!( + verify_bootstrap(&body, &roots, NOW, false, true).unwrap_err(), + BootstrapError::InvalidPayload + ); + + let mut payload = valid_payload(); + let extra = SigningKey::from_bytes(&[13; 32]); + payload.regions[0] + .locator_keys + .push(key("aaa_out_of_order", *extra.verifying_key().as_bytes())); + let body = signed_envelope(&signing, "test_root_1", &payload); + assert_eq!( + verify_bootstrap(&body, &roots, NOW, false, true).unwrap_err(), + BootstrapError::InvalidPayload + ); + + let mut payload = valid_payload(); + payload.regions[1].locator_keys[0].kid = "locator_cn_1".to_owned(); + let body = signed_envelope(&signing, "test_root_1", &payload); + assert_eq!( + verify_bootstrap(&body, &roots, NOW, false, true).unwrap_err(), + BootstrapError::InvalidPayload + ); + + let mut payload = valid_payload(); + let reused_x = payload.regions[0].locator_keys[0].x.clone(); + payload.regions[1].locator_keys[0].x = reused_x; + let body = signed_envelope(&signing, "test_root_1", &payload); + assert_eq!( + verify_bootstrap(&body, &roots, NOW, false, true).unwrap_err(), + BootstrapError::InvalidPayload + ); + + let canonical = serde_json::to_vec(&valid_payload()).unwrap(); + let mut value: serde_json::Value = serde_json::from_slice(&canonical).unwrap(); + value + .as_object_mut() + .unwrap() + .insert("unexpected".to_owned(), serde_json::Value::Bool(true)); + let raw = serde_json::to_vec(&value).unwrap(); + let mut signing_bytes = BOOTSTRAP_CONTEXT.to_vec(); + signing_bytes.extend_from_slice(&raw); + let envelope = BootstrapEnvelope { + version: 1, + kid: "test_root_1".to_owned(), + payload: URL_SAFE_NO_PAD.encode(raw), + signature: URL_SAFE_NO_PAD.encode(signing.sign(&signing_bytes).to_bytes()), + }; + let body = serde_json::to_vec(&envelope).unwrap(); + assert_eq!( + verify_bootstrap(&body, &roots, NOW, false, true).unwrap_err(), + BootstrapError::InvalidPayload + ); +} + +#[test] +fn payload_rejects_invalid_time_urls_kids_and_low_order_keys() { + let signing = SigningKey::from_bytes(&[7; 32]); + let roots = roots(&signing, "test_root_1"); + for mutate in [ + |payload: &mut BootstrapPayload| payload.not_after_unix = payload.not_before_unix, + |payload: &mut BootstrapPayload| { + payload.regions[0].relay_url = "ws://127.0.0.1:1/v1/tunnel".to_owned() + }, + |payload: &mut BootstrapPayload| { + payload.regions[0].locator_url = "https://locator.example/wrong".to_owned() + }, + |payload: &mut BootstrapPayload| { + payload.regions[0].locator_keys[0].kid = "bad.kid".to_owned() + }, + |payload: &mut BootstrapPayload| { + payload.regions[0].relay_x25519_keys[0].x = URL_SAFE_NO_PAD.encode([0; 32]) + }, + |payload: &mut BootstrapPayload| { + let mut noncanonical = [0xff; 32]; + noncanonical[0] = 0xed; + noncanonical[31] = 0x7f; + payload.regions[0].relay_x25519_keys[0].x = URL_SAFE_NO_PAD.encode(noncanonical) + }, + ] { + let mut payload = valid_payload(); + mutate(&mut payload); + let body = signed_envelope(&signing, "test_root_1", &payload); + assert_eq!( + verify_bootstrap(&body, &roots, NOW, false, true).unwrap_err(), + BootstrapError::InvalidPayload + ); + } +} + +#[test] +fn locator_keys_reject_noncanonical_ed25519_encodings() { + let noncanonical = noncanonical_ed25519_encoding(); + assert!(VerifyingKey::from_bytes(&noncanonical).is_ok_and(|key| !key.is_weak())); + assert_eq!( + canonical_ed25519_key(noncanonical, BootstrapError::InvalidPayload), + Err(BootstrapError::InvalidPayload) + ); + + let signing = SigningKey::from_bytes(&[7; 32]); + let roots = roots(&signing, "test_root_1"); + let mut payload = valid_payload(); + payload.regions[0].locator_keys[0].x = URL_SAFE_NO_PAD.encode(noncanonical); + assert_eq!( + verify_bootstrap( + &signed_envelope(&signing, "test_root_1", &payload), + &roots, + NOW, + false, + true, + ) + .unwrap_err(), + BootstrapError::InvalidPayload + ); +} + +#[test] +fn integer_wire_boundaries_match_the_go_and_browser_contract() { + let signing = SigningKey::from_bytes(&[7; 32]); + let roots = roots(&signing, "test_root_1"); + let mut boundary = valid_payload(); + boundary.generation = MAX_SAFE_INTEGER; + boundary.not_before_unix = MAX_UNIX_SECOND - 3_600; + boundary.not_after_unix = MAX_UNIX_SECOND; + assert!(verify_bootstrap( + &signed_envelope(&signing, "test_root_1", &boundary), + &roots, + MAX_UNIX_SECOND - 1_800, + false, + true, + ) + .is_ok()); + + for mutate in [ + |payload: &mut BootstrapPayload| payload.generation = MAX_SAFE_INTEGER + 1, + |payload: &mut BootstrapPayload| { + payload.not_before_unix = MAX_UNIX_SECOND + 1; + payload.not_after_unix = MAX_UNIX_SECOND + 2; + }, + |payload: &mut BootstrapPayload| { + payload.not_before_unix = MAX_UNIX_SECOND - 60; + payload.not_after_unix = MAX_UNIX_SECOND + 1; + }, + ] { + let mut payload = valid_payload(); + mutate(&mut payload); + assert_eq!( + verify_bootstrap( + &signed_envelope(&signing, "test_root_1", &payload), + &roots, + NOW, + false, + false, + ) + .unwrap_err(), + BootstrapError::InvalidPayload + ); + } +} + +#[test] +fn payload_urls_are_canonical_and_unique_across_regions() { + assert!(bootstrap_url::parse("wss://[2001:db8::1]/v1/tunnel", "/v1/tunnel").is_some()); + for (value, path) in [ + ("wss://Relay.example.cn/v1/tunnel", "/v1/tunnel"), + ("wss://relay.example.cn:443/v1/tunnel", "/v1/tunnel"), + ("wss://relay.example.cn:0444/v1/tunnel", "/v1/tunnel"), + ("wss://[fe80::1%25en0]/v1/tunnel", "/v1/tunnel"), + ("wss://[2001:DB8::1]/v1/tunnel", "/v1/tunnel"), + ( + "wss://[2001:0db8:0000:0000:0000:0000:0000:0001]/v1/tunnel", + "/v1/tunnel", + ), + ("wss://192.168.001.001/v1/tunnel", "/v1/tunnel"), + ("wss://127.1/v1/tunnel", "/v1/tunnel"), + ("wss://2130706433/v1/tunnel", "/v1/tunnel"), + ("wss://relay.example.cn/v1/%74unnel", "/v1/tunnel"), + ("wss://relay.example.cn:0/v1/tunnel", "/v1/tunnel"), + ("wss://relay.example.cn:65536/v1/tunnel", "/v1/tunnel"), + ("wss://relay.example.cn:99999/v1/tunnel", "/v1/tunnel"), + ("wss://relay.example.cn:/v1/tunnel", "/v1/tunnel"), + (" wss://relay.example.cn/v1/tunnel", "/v1/tunnel"), + ("https://Locator.example.cn/v1/locator", "/v1/locator"), + ("https://locator.example.cn:443/v1/locator", "/v1/locator"), + ("https://locator.example.cn:0/v1/locator", "/v1/locator"), + ("https://locator.example.cn:65536/v1/locator", "/v1/locator"), + ] { + assert!(bootstrap_url::parse(value, path).is_none(), "{value}"); + } + + let signing = SigningKey::from_bytes(&[7; 32]); + let roots = roots(&signing, "test_root_1"); + for mutate in [ + |payload: &mut BootstrapPayload| { + payload.regions[1].relay_url = payload.regions[0].relay_url.clone() + }, + |payload: &mut BootstrapPayload| { + payload.regions[1].locator_url = payload.regions[0].locator_url.clone() + }, + ] { + let mut payload = valid_payload(); + mutate(&mut payload); + assert_eq!( + verify_bootstrap( + &signed_envelope(&signing, "test_root_1", &payload), + &roots, + NOW, + false, + true, + ) + .unwrap_err(), + BootstrapError::InvalidPayload + ); + } +} + +#[test] +fn validity_and_generation_fail_closed() { + let signing = SigningKey::from_bytes(&[7; 32]); + let roots = roots(&signing, "test_root_1"); + let mut expired = valid_payload(); + expired.not_before_unix = NOW - 600; + expired.not_after_unix = NOW; + let body = signed_envelope(&signing, "test_root_1", &expired); + assert_eq!( + verify_bootstrap(&body, &roots, NOW, false, true).unwrap_err(), + BootstrapError::Expired + ); + + let previous = verify_bootstrap( + &signed_envelope(&signing, "test_root_1", &valid_payload()), + &roots, + NOW, + false, + true, + ) + .unwrap(); + let mut lower = valid_payload(); + lower.generation = 6; + let lower = verify_bootstrap( + &signed_envelope(&signing, "test_root_1", &lower), + &roots, + NOW, + false, + true, + ) + .unwrap(); + assert_eq!( + reject_rollback(&previous, &lower), + Err(BootstrapError::Rollback) + ); + + let mut rewritten = valid_payload(); + rewritten.regions[0].relay_url = "wss://relay-cn-2.example/v1/tunnel".to_owned(); + let rewritten = verify_bootstrap( + &signed_envelope(&signing, "test_root_1", &rewritten), + &roots, + NOW, + false, + true, + ) + .unwrap(); + assert_eq!( + reject_rollback(&previous, &rewritten), + Err(BootstrapError::Rollback) + ); +} + +#[test] +fn production_bootstrap_endpoint_is_exact_https_only() { + assert!( + parse_bootstrap_endpoint("https://hub.openpencil.dev/api/v1/collaboration/bootstrap") + .is_ok() + ); + for endpoint in [ + "http://hub.openpencil.dev/api/v1/collaboration/bootstrap", + " https://hub.openpencil.dev/api/v1/collaboration/bootstrap", + "https://Hub.openpencil.dev/api/v1/collaboration/bootstrap", + "https://hub.openpencil.dev:443/api/v1/collaboration/bootstrap", + "https://hub.openpencil.dev:0/api/v1/collaboration/bootstrap", + "https://hub.openpencil.dev:65536/api/v1/collaboration/bootstrap", + "https://hub.openpencil.dev:99999/api/v1/collaboration/bootstrap", + "https://hub.openpencil.dev:/api/v1/collaboration/bootstrap", + "https://[fe80::1%25en0]/api/v1/collaboration/bootstrap", + "https://hub.openpencil.dev/api/v1/collaboration/%62ootstrap", + "https://hub.openpencil.dev/api/v1/collaboration/bootstrap/", + "https://user@hub.openpencil.dev/api/v1/collaboration/bootstrap", + "https://hub.openpencil.dev/api/v1/collaboration/bootstrap?q=1", + "https://hub.openpencil.dev/api/v1/collaboration/bootstrap#fragment", + ] { + assert_eq!( + parse_bootstrap_endpoint(endpoint).unwrap_err(), + BootstrapError::InvalidEndpoint + ); + } + assert!(parse_bootstrap_endpoint_with_policy( + "http://127.0.0.1:34123/api/v1/collaboration/bootstrap", + true + ) + .is_ok()); + assert!(parse_bootstrap_endpoint_with_policy( + "http://127.0.0.1:34123/api/v1/collaboration/bootstrap", + false + ) + .is_err()); + assert!(parse_bootstrap_endpoint_with_policy( + "http://localhost:34123/api/v1/collaboration/bootstrap", + true + ) + .is_err()); +} + +#[test] +fn etag_is_strong_and_bound_to_exact_envelope_bytes() { + let body = b"{\"version\":1}"; + let etag = strong_etag(body); + assert_eq!(etag.len(), 66); + assert!(etag.starts_with('"') && etag.ends_with('"')); + let mut headers = HeaderMap::new(); + headers.insert(ETAG, HeaderValue::from_str(&etag).unwrap()); + assert_eq!(response_etag(&headers, body).unwrap(), etag); + assert!(response_etag(&headers, b"different").is_err()); +} + +#[test] +fn provider_reuses_a_valid_signed_cache_on_transport_failure() { + let signing = SigningKey::from_bytes(&[7; 32]); + let body = signed_envelope(&signing, "test_root_1", &valid_payload()); + let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).unwrap(); + let address = listener.local_addr().unwrap(); + let endpoint = format!("http://{address}{BOOTSTRAP_PATH}"); + let etag = strong_etag(&body); + let response_body = body.clone(); + let server = thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let mut request = [0_u8; 2_048]; + let _ = stream.read(&mut request).unwrap(); + write!( + stream, + "HTTP/1.1 200 OK\r\nContent-Type: application/json; charset=utf-8\r\nETag: {etag}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + response_body.len() + ) + .unwrap(); + stream.write_all(&response_body).unwrap(); + }); + let cache_root = + std::env::temp_dir().join(format!("op-bootstrap-cache-{}-{}", std::process::id(), NOW)); + let _ = std::fs::remove_dir_all(&cache_root); + std::fs::create_dir_all(&cache_root).unwrap(); + let provider = EnvironmentRelayBootstrapProvider { + endpoint: Url::parse(&endpoint).unwrap(), + roots: roots(&signing, "test_root_1"), + development_http: true, + cache_path: cache_root.join(BOOTSTRAP_CACHE_FILE), + }; + assert_eq!(provider.load_inner(NOW).unwrap().generation, 7); + server.join().unwrap(); + assert_eq!(provider.load_inner(NOW).unwrap().generation, 7); + let _ = std::fs::remove_dir_all(cache_root); +} + +#[test] +fn provider_sends_etag_and_accepts_only_matching_not_modified() { + let signing = SigningKey::from_bytes(&[7; 32]); + let body = signed_envelope(&signing, "test_root_1", &valid_payload()); + let etag = strong_etag(&body); + let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).unwrap(); + let address = listener.local_addr().unwrap(); + let endpoint = format!("http://{address}{BOOTSTRAP_PATH}"); + let (seen, received) = mpsc::sync_channel(1); + let response_body = body.clone(); + let response_etag = etag.clone(); + let server = thread::spawn(move || { + for index in 0..2 { + let (mut stream, _) = listener.accept().unwrap(); + let mut request = [0_u8; 4_096]; + let count = stream.read(&mut request).unwrap(); + let request = String::from_utf8_lossy(&request[..count]).to_string(); + if index == 0 { + write!( + stream, + "HTTP/1.1 200 OK\r\nContent-Type: application/json; charset=utf-8\r\nETag: {response_etag}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + response_body.len() + ) + .unwrap(); + stream.write_all(&response_body).unwrap(); + } else { + seen.send(request).unwrap(); + write!( + stream, + "HTTP/1.1 304 Not Modified\r\nETag: {response_etag}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n" + ) + .unwrap(); + } + } + }); + let cache_root = + std::env::temp_dir().join(format!("op-bootstrap-etag-{}-{}", std::process::id(), NOW)); + let _ = std::fs::remove_dir_all(&cache_root); + std::fs::create_dir_all(&cache_root).unwrap(); + let provider = EnvironmentRelayBootstrapProvider { + endpoint: Url::parse(&endpoint).unwrap(), + roots: roots(&signing, "test_root_1"), + development_http: true, + cache_path: cache_root.join(BOOTSTRAP_CACHE_FILE), + }; + provider.load_inner(NOW).unwrap(); + provider.load_inner(NOW).unwrap(); + let request = received.recv().unwrap(); + assert!(request + .to_ascii_lowercase() + .contains(&format!("if-none-match: {}", etag).to_ascii_lowercase())); + server.join().unwrap(); + let _ = std::fs::remove_dir_all(cache_root); +} diff --git a/crates/op-host-desktop/src/collab_runtime/relay_bootstrap_url.rs b/crates/op-host-desktop/src/collab_runtime/relay_bootstrap_url.rs new file mode 100644 index 000000000..be8777d9a --- /dev/null +++ b/crates/op-host-desktop/src/collab_runtime/relay_bootstrap_url.rs @@ -0,0 +1,27 @@ +use reqwest::Url; + +use super::MAX_URL_BYTES; + +pub(super) fn parse(value: &str, path: &str) -> Option { + if value.is_empty() || value.len() > MAX_URL_BYTES || value.trim() != value || !value.is_ascii() + { + return None; + } + let endpoint = Url::parse(value).ok()?; + let host = endpoint.host_str()?; + if host.is_empty() + || !host.is_ascii() + || host != host.to_ascii_lowercase() + || host.contains('%') + || !endpoint.username().is_empty() + || endpoint.password().is_some() + || endpoint.query().is_some() + || endpoint.fragment().is_some() + || endpoint.path() != path + || endpoint.port().is_some_and(|port| port == 0 || port == 443) + || endpoint.as_str() != value + { + return None; + } + Some(endpoint) +} diff --git a/crates/op-host-desktop/src/collab_runtime/relay_tests.rs b/crates/op-host-desktop/src/collab_runtime/relay_tests.rs index 599167f85..75f6ece47 100644 --- a/crates/op-host-desktop/src/collab_runtime/relay_tests.rs +++ b/crates/op-host-desktop/src/collab_runtime/relay_tests.rs @@ -4,10 +4,19 @@ use super::*; use ed25519_dalek::{Signer, SigningKey}; use op_collab_relay_control_plane::SignedLocatorResponse; +struct CountingBootstrapProvider(std::sync::atomic::AtomicUsize); + +impl RelayBootstrapProvider for CountingBootstrapProvider { + fn load(&self) -> Result, CollabRuntimeFailure> { + self.0.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + Err(CollabRuntimeFailure::RelayUnavailable) + } +} + struct SigningControlPlane(SigningKey); -impl RelayLocatorControlPlane for SigningControlPlane { - fn publish_route( +impl SigningControlPlane { + fn publish_for_test( &self, draft: OwnerPublishDraft, _ticket: &OpaqueCollabTicket, @@ -33,8 +42,9 @@ impl RelayLocatorControlPlane for SigningControlPlane { ); let response = SignedLocatorResponse::decode(&locator.encode()) .map_err(|_| CollabRuntimeFailure::RelayUnavailable)?; - let verifier = Ed25519LocatorVerifier { - keys: HashMap::from([(key_id.as_str().to_owned(), self.0.verifying_key())]), + let verifier = SingleKeyVerifier { + key_id: key_id.clone(), + key: self.0.verifying_key(), }; let published = draft .complete(response, &verifier, now) @@ -46,6 +56,40 @@ impl RelayLocatorControlPlane for SigningControlPlane { } } +impl RelayLocatorControlPlane for SigningControlPlane { + fn publish_route( + &self, + draft: OwnerPublishDraft, + ticket: &OpaqueCollabTicket, + _region: &RelayBootstrapRegion, + ) -> Result { + self.publish_for_test(draft, ticket) + } +} + +struct SingleKeyVerifier { + key_id: LocatorKeyId, + key: ed25519_dalek::VerifyingKey, +} + +impl RelayLocatorVerifier for SingleKeyVerifier { + fn verify( + &self, + key_id: &LocatorKeyId, + canonical_signing_bytes: &[u8], + signature: &[u8; 64], + ) -> bool { + key_id == &self.key_id + && self + .key + .verify_strict( + canonical_signing_bytes, + &ed25519_dalek::Signature::from_bytes(signature), + ) + .is_ok() + } +} + #[test] fn development_unsigned_requires_all_three_gates() { for (debug, loopback, value, expected) in [ @@ -62,31 +106,6 @@ fn development_unsigned_requires_all_three_gates() { } } -#[test] -fn locator_key_parser_is_bounded_and_verifies_ed25519() { - let signing = SigningKey::from_bytes(&[7_u8; 32]); - let encoded = URL_SAFE_NO_PAD.encode(signing.verifying_key().as_bytes()); - let verifier = parse_locator_keys(&format!("current={encoded}")).unwrap(); - let bytes = b"canonical locator bytes"; - let signature = signing.sign(bytes).to_bytes(); - assert!(verifier.verify(&LocatorKeyId::new("current").unwrap(), bytes, &signature)); - assert!(!verifier.verify(&LocatorKeyId::new("unknown").unwrap(), bytes, &signature)); - assert!(parse_locator_keys(&format!("a={encoded},a={encoded}")).is_err()); - assert!(parse_locator_keys("missing-separator").is_err()); -} - -#[test] -fn relay_x25519_pin_parser_is_canonical_bounded_and_rejects_duplicates() { - let encoded = URL_SAFE_NO_PAD.encode([11_u8; 32]); - assert!(parse_relay_x25519_keys(&format!("relay-cn={encoded}")).is_ok()); - assert!(parse_relay_x25519_keys(&format!("relay-cn={encoded},relay-cn={encoded}")).is_err()); - assert!(parse_relay_x25519_keys(&format!("relay-cn={encoded}=")).is_err()); - assert!( - parse_relay_x25519_keys(&format!("relay-cn={}", URL_SAFE_NO_PAD.encode([0_u8; 32]))) - .is_err() - ); -} - #[test] fn injected_control_plane_publishes_a_ticket_bound_owner_route() { let signing = SigningKey::from_bytes(&[9_u8; 32]); @@ -100,7 +119,7 @@ fn injected_control_plane_publishes_a_ticket_bound_owner_route() { .unwrap(); let ticket = OpaqueCollabTicket::new(b"header.payload.signature".to_vec()).expect("ticket"); let route = SigningControlPlane(signing) - .publish_route(draft, &ticket) + .publish_for_test(draft, &ticket) .expect("published route"); assert_eq!(route.locator().claims().home_region(), RelayRegion::Cn); assert_eq!( @@ -109,45 +128,34 @@ fn injected_control_plane_publishes_a_ticket_bound_owner_route() { ); let fragment = RelayInviteV1::new(&route).to_fragment(); let invite = RelayInviteV1::from_fragment(&fragment).unwrap(); - let verifier = Ed25519LocatorVerifier { - keys: HashMap::from([("current".to_owned(), verifying_key)]), + let verifier = SingleKeyVerifier { + key_id: LocatorKeyId::new("current").unwrap(), + key: verifying_key, }; let now = unix_time_ms().unwrap() / 1_000; let verified = invite.verify(&verifier, now).expect("signed invite"); assert_eq!(verified.locator().claims().home_region(), RelayRegion::Cn); - assert!(endpoint_for_region( - verified.locator().claims().home_region(), - Some("wss://global-ingress.example.com/v1/tunnel"), - Some("malformed-global-url"), - ) - .is_ok()); + + let provider = std::sync::Arc::new(CountingBootstrapProvider( + std::sync::atomic::AtomicUsize::new(0), + )); + let route = guest_route_from_parsed_invite(invite, provider.clone()); + assert_eq!( + provider.0.load(std::sync::atomic::Ordering::SeqCst), + 0, + "the UI invite parser must not fetch bootstrap HTTP" + ); + assert_eq!( + route.connection_path(), + CollabConnectionPathUi::Relay { + home_region: CollabRelayRegion::China + } + ); } #[test] -fn overseas_guest_follows_cn_home_region_and_never_falls_back() { - // An overseas deployment may point the logical CN endpoint at a - // Global L4/TLS-passthrough ingress whose upstream remains CN. - let cn_url = "wss://global-ingress.example.com/v1/tunnel"; - let cn = RelayEndpoint::parse(cn_url).unwrap(); - assert_eq!( - endpoint_for_region(RelayRegion::Cn, Some(cn_url), Some("malformed-global-url")).unwrap(), - cn - ); +fn cn_home_region_stays_cn_in_the_collaboration_ui() { assert_eq!(ui_region(RelayRegion::Cn), CollabRelayRegion::China); - let error = endpoint_for_region( - RelayRegion::Cn, - None, - Some("wss://global-home.example.com/v1/tunnel"), - ) - .unwrap_err(); - assert_eq!(error.failure, CollabRuntimeFailure::RelayRegionUnavailable); - let error = endpoint_for_region( - RelayRegion::Cn, - Some("malformed-cn-url"), - Some("wss://global-home.example.com/v1/tunnel"), - ) - .unwrap_err(); - assert_eq!(error.failure, CollabRuntimeFailure::RelayUnavailable); } #[test] diff --git a/docs/security/p2p-collaboration-threat-model.md b/docs/security/p2p-collaboration-threat-model.md index 17650072f..6fc2e7093 100644 --- a/docs/security/p2p-collaboration-threat-model.md +++ b/docs/security/p2p-collaboration-threat-model.md @@ -1,8 +1,9 @@ # P2P collaboration threat model and trust boundary -Status: public M1 implementation checkpoint, 2026-07-28; the private identity -source integration is implemented, while production provisioning and the full -real-platform acceptance matrix remain pending. +Status: public M1 and M2.1 source checkpoint, 2026-07-30; the private identity +source integration and public relay/bootstrap client boundaries are +implemented, while production provisioning, op-hub/relay/locator deployment, +and the full real-platform acceptance matrix remain pending. This document is the security contract for OpenPencil peer-to-peer collaboration. It is intentionally public. The protocol, parsers, state @@ -19,14 +20,22 @@ A manually entered publicly routable address can exercise the current TCP and Noise path across sites when firewall and NAT policy permit it, but M1 has no rendezvous, NAT traversal, or relay and does not claim robust Internet reachability. +M2.1 adds an owner-anchored public WSS relay path. An overseas peer may join a +signed `home_region=cn` invite through a Global L4 ingress that fixed-backhauls +to the same CN relay and locator services; being overseas does not change the +signed home region or authorize a Global-home fallback. Relay infrastructure +forwards bounded prelude, Noise handshake, and inner Noise ciphertext as an +opaque stream. It can observe admission and routing metadata at the documented +layers, but it must not decrypt or persist document frames. ZSeven services authenticate accounts and are the production issuer boundary for short-lived admission tickets. The private issuer and credential-bearing provider source implementations now exist, including a strict JWKS profile, persistent rotation ledger, Unix-HSM peer authentication, and an append-only ABI-v2 client contract. Production HSM keys, protected static archives, deployment policy, and hardened runners are separate provisioning gates and -are not claimed complete at this checkpoint. The services do not store or -relay document content. +are not claimed complete at this checkpoint. Identity and bootstrap services +do not store document content; relay services forward but do not decrypt or +persist it. The implementation must: @@ -56,6 +65,8 @@ traffic reaches OpenPencil. | Document and presence content | Confidentiality and integrity in transit; no disclosure before admission | | ZSeven device credential | Private implementation and platform-protected storage; never exposed through the open collaboration API | | Collaboration signing private key | Issuer/HSM boundary only; never shipped to a client or repository | +| Collaboration bootstrap root private key | Offline signing/HSM boundary only; never held by the desktop or online op-hub service | +| Signed relay bootstrap and verified LKG | Authenticity, canonical encoding, bounded validity, rollback resistance, and atomic CN/Global endpoint/key selection | | Short-lived collaboration ticket | Treated as a bearer credential, redacted and zeroized where owned | | Noise X25519 static private key | Local-only, zeroized in memory, stored with platform/file protections | | Account subject and device id | Derived only from verified claims; omitted from mDNS and routine logs | @@ -70,19 +81,28 @@ traffic reaches OpenPencil. local X25519 public key. 2. The ticket issuer signs the fixed public claims profile. It does not return signing keys to the client. -3. mDNS advertises only an ephemeral discovery id, protocol version, and TCP +3. For a public relay session, the desktop fetches one signed collaboration + bootstrap from a startup-configured exact HTTPS endpoint. It verifies the + canonical payload under the embedded Ed25519 root and selects one complete + CN or Global entry. The bootstrap mirror, DNS, TLS endpoint, invite, and peer + cannot replace that trust root. +4. An owner publishes a route to the selected entry's locator service. A guest + selects the entry named by the invite's signed `home_region`. Both use the + same verified snapshot for the relay URL, locator URL and verification keys, + and relay challenge X25519 pins. +5. mDNS advertises only an ephemeral discovery id, protocol version, and TCP port. Discovery is a locator, not an authentication statement. -4. Peers complete `Noise_XX_25519_ChaChaPoly_BLAKE2s`. The responder prelude is +6. Peers complete `Noise_XX_25519_ChaChaPoly_BLAKE2s`. The responder prelude is included in the Noise prologue. -5. Tickets are exchanged inside the encrypted Noise channel. The open verifier +7. Tickets are exchanged inside the encrypted Noise channel. The open verifier checks signature, issuer, audience, version, scope, time, identifiers, and the equality of `dh_pub_x25519` with the observed remote Noise static key. Optional `display_name` and `avatar_url` claims are accepted only from this signed payload; names are bounded and control-free, and avatar URLs must be bounded HTTPS URLs without credentials or fragments. -6. Only after both admission checks succeed may the owner send a welcome, +8. Only after both admission checks succeed may the owner send a welcome, snapshot, commit, or presence message. -7. The owner assigns the connection role and is the serialization point for +9. The owner assigns the connection role and is the serialization point for accepted commits. A guest cannot acquire permissions by putting a role, author, subject, or device id in an untrusted message. @@ -131,6 +151,84 @@ authorized in a higher generation before either region changes signing state. Mirror availability, consistency, HSM provisioning, and physical multi-region timing tests remain production gates. +### Signed collaboration bootstrap and cross-region relay routing + +The desktop's former direct injection of five relay/locator endpoint and public +key values is retired. Production now configures +`OPENPENCIL_COLLAB_BOOTSTRAP_URL`, while an owner additionally retains +`OPENPENCIL_COLLAB_RELAY_HOME_REGION=cn|global` as a local home selector. A +guest obtains its home region only from the signed invite. The embedded +`openpencil-collab-root-v1` Ed25519 public key currently has the same bytes as +the collaboration union-policy root, but the two source constants are not yet +single-sourced; deployment and tests must not assume source-level coupling. + +The bootstrap URL must be HTTPS with the exact +`/api/v1/collaboration/bootstrap` path and no credentials, query, or fragment. +The envelope and decoded payload must each match their canonical JSON +re-encoding. Payload, signature, and public-key fields use unpadded canonical +base64url. The signature is Ed25519 over: + +```text +"openpencil/op-hub/collaboration-bootstrap/v1\0" || +canonical_payload_json_bytes +``` + +The domain separator prevents a valid signature for another collaboration +artifact from being interpreted as a bootstrap signature. A payload has one +non-zero generation and one validity window of at most seven days, and contains +exactly one CN and one Global entry in the same signed snapshot. Each entry +atomically contains its exact relay WSS URL, locator HTTPS URL, locator +Ed25519 public keys, and relay-challenge X25519 public keys. The desktop allows +at most 300 seconds of future `not_before` clock skew, while `not_after` remains +exclusive. + +An overseas guest joining a signed `home_region=cn` invite therefore selects +the complete CN entry from that snapshot. GeoDNS or an audited edge may land +the CN entry's logical hostnames at a Global L4 ingress and fixed-backhaul the +untouched inner TLS stream to CN. The guest must not select the Global entry +because of its physical location, combine a Global endpoint with CN keys, or +fall back to a Global home when the CN path fails. The CN locator signer and CN +relay authenticator remain the authoritative route and admission boundaries. + +The desktop cache is bound to the exact bootstrap endpoint and stores the +signed body with its strong SHA-256 ETag. A still-current, freshly reverified +last-known-good snapshot may be used after a fetch/request-send failure before +a response is available, a status other than 200/304, or a valid 304 with the +exact cached ETag. A body-read failure after a 200 response, or any invalid 200 +response, does not fall back to LKG. A lower generation, or different signed +payload bytes at the same generation, fails closed. An expired snapshot is not +used for routing, but remains a rollback baseline. This high-water mark exists +only in a valid persisted endpoint-bound cache: changing the bootstrap URL, or +deleting, corrupting, making unreadable, or failing to persist the cache, does +not preserve a global generation floor. + +Domestic and overseas op-hub sites may serve the same byte-identical signed +envelope, but the current service does not replicate or hot-reload snapshots; +operations must deploy the same artifact to both sites. Each op-hub process +loads `OP_HUB_BOOTSTRAP_FILE`, verifies it with +`OP_HUB_BOOTSTRAP_ROOT_KEYS`, and enforces +`OP_HUB_BOOTSTRAP_MIN_GENERATION` before serving an immutable in-memory +snapshot. The online service never receives the offline bootstrap private key. + +Debug plaintext is a narrow exception, not a production alternate trust path. +Only a test/debug build with +`OPENPENCIL_COLLAB_BOOTSTRAP_DEV_HTTP=1` may fetch bootstrap HTTP from a +numeric-loopback address. Additional +`OPENPENCIL_COLLAB_BOOTSTRAP_DEV_ROOT_KEYS` are considered only in that mode; +plaintext locator and relay endpoints in the snapshot must also be numeric +loopback. Unsigned relay operation separately requires +`OPENPENCIL_COLLAB_RELAY_DEV_UNSIGNED=1` in a debug build and numeric-loopback +WebSocket endpoint. `localhost`, non-loopback plaintext, and release builds are +not covered by these exceptions. + +Client bootstrap does not replace service-side secret and policy +provisioning. The relay server retains +`OPENPENCIL_COLLAB_RELAY_TICKET_POLICY_FILE`, +`OPENPENCIL_COLLAB_RELAY_LOCATOR_KEYS_FILE`, and +`OPENPENCIL_COLLAB_RELAY_X25519_KEYS_FILE`; the locator server retains +`OPENPENCIL_COLLAB_LOCATOR_TICKET_POLICY_FILE`. Those files remain bounded +operator/HSM-side production inputs and are not desktop discovery settings. + ## Public and private ownership The default is open source. Code stays private only when publishing it would @@ -141,6 +239,8 @@ expose an account credential or a production signing secret. | Wire protocol, exact diff/apply, canonical hash, owner/guest state machines | Public `openpencil/crates/op-collab` | Reviewable deterministic behavior; wasm-compatible | | Noise/TCP framing, admission, limits, queues, discovery, key-store interface and safe fallback | Public `openpencil/crates/op-collab-transport` | Security comes from open protocol and audited libraries | | Ticket claims/profile, signed-union-policy and legacy JWKS parser/cache, Ed25519 verifier, provider trait, stub, ABI declarations | Public `openpencil/crates/op-auth-bridge` | Trust decisions must remain reviewable | +| Relay protocol/client/server, signed locator verifier, bootstrap verifier/cache, canonical wire and rollback tests | Public `openpencil` crates and desktop host | Endpoint, key-selection, and data-plane trust decisions must remain reviewable | +| Signed bootstrap HTTP serving and deployment | Private `op-hub` service; public signed response | The online service holds no root private key; private deployment topology is not a cryptographic control | | Deterministic issuer and rotation fixtures | Public, compiled only for tests or `test-issuer` | Contain deliberately public seeds and a `.invalid` issuer; production verifier rejects that issuer | | Host integration, UI, recovery, diagnostics, and smoke tests | Public `openpencil` | No credential-handling reason to hide them | | Device token and authenticated ticket request implementation | Private `op-platform` real provider | Holds the account credential and platform storage integration | @@ -229,6 +329,34 @@ for the network timeout or publishing a late result. The trait default can only check before and after a blocking third-party fetch; any other production blocking adapter must override the cancellable method. +### Bootstrap mirror compromise, rollback, and region confusion + +A compromised op-hub mirror, DNS path, CDN, or TLS terminator can deny service, +replay bytes, or return malformed data, but cannot authorize a new endpoint or +public key without a valid domain-separated root signature. Canonical JSON and +base64url checks remove alternate encodings; bounded response, payload, region, +and key counts constrain parser and allocation work. An invalid signed time +window, weak key, unknown root, malformed ETag, lower generation, or +same-generation rewrite fails closed. + +LKG is an availability control, not a trust bypass. It is usable only while its +signature and validity window still verify, and it does not conceal a malformed +200 response. Operators must nevertheless treat cache deletion, corruption, +unreadability, persistence failure, bootstrap URL changes, and unsynchronized +regional mirrors as rollback-risk events because the client has no durable +cross-endpoint global generation ledger without a valid cache. Production +rollouts must publish one byte-identical envelope to domestic and overseas +mirrors, advance the op-hub minimum-generation floor, and preserve overlapping +region keys inside the signed snapshot. Rotating the embedded root requires a +coordinated client-and-service release; the current production desktop does +not load additional roots from runtime configuration. + +The invite's signed `home_region` is authoritative. Physical geolocation, +bootstrap mirror location, DNS answer, and edge ingress do not authorize a +different region. If an overseas client cannot reach the CN entry for a CN-home +invite, it closes or reports relay unavailable; it does not mix Global +endpoint/key material or silently create a Global-home session. + ### Unauthorized edits and identity injection Verified identity metadata is constructed only by the admission boundary.