feat(desktop): load signed relay bootstrap

This commit is contained in:
Kayshen-X 2026-07-30 22:51:48 +08:00
parent 7639ac2c0f
commit 4b3d3334ba
12 changed files with 1741 additions and 313 deletions

1
Cargo.lock generated
View file

@ -3961,6 +3961,7 @@ dependencies = [
"rfd",
"serde",
"serde_json",
"sha2",
"skia-safe",
"socket2 0.6.3",
"static_assertions",

View file

@ -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" }

View file

@ -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;

View file

@ -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)
}

View file

@ -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<dyn RelayBootstrapProvider>,
}
pub(super) struct RelayOwnerRequest {
endpoint: RelayEndpoint,
home_region: RelayRegion,
development_unsigned: bool,
provider: std::sync::Arc<dyn RelayBootstrapProvider>,
control_plane: std::sync::Arc<dyn RelayLocatorControlPlane>,
}
@ -117,6 +107,7 @@ pub(crate) trait RelayLocatorControlPlane: Send + Sync {
&self,
draft: OwnerPublishDraft,
ticket: &OpaqueCollabTicket,
region: &RelayBootstrapRegion,
) -> Result<VerifiedRelayRoute, CollabRuntimeFailure>;
}
@ -127,10 +118,10 @@ impl RelayLocatorControlPlane for EnvironmentRelayLocatorControlPlane {
&self,
draft: OwnerPublishDraft,
ticket: &OpaqueCollabTicket,
region: &RelayBootstrapRegion,
) -> Result<VerifiedRelayRoute, CollabRuntimeFailure> {
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<Self, CollabRuntimeFailure> {
// 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(&region.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<DeviceStaticKey>,
local: std::sync::Arc<std::sync::RwLock<LocalAdmission>>,
) -> Result<Self, CollabRuntimeFailure> {
// 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(&region.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(&region.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<dyn RelayLocatorControlPlane>,
) -> Result<Option<RelayOwnerRequest>, 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<GuestConnectionRoute, CollabRuntimeError> {
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<dyn RelayBootstrapProvider>,
) -> 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<SocketAddr>, Option<String>, 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<Option<RelayEndpoint>, 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<RelayRegion, CollabRuntimeError> {
match value {
Some("cn") => Ok(RelayRegion::Cn),
@ -404,152 +400,31 @@ fn parse_home_region(value: Option<&str>) -> Result<RelayRegion, CollabRuntimeEr
}
}
#[cfg(test)]
fn endpoint_for_region(
region: RelayRegion,
cn: Option<&str>,
global: Option<&str>,
) -> Result<RelayEndpoint, CollabRuntimeError> {
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<RelayEndpoint, CollabRuntimeError> {
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<String, VerifyingKey>,
}
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<Ed25519LocatorVerifier, CollabRuntimeError> {
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<String, CollabRuntimeFailure> {
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<RelayLocatorHttpClient<Ed25519LocatorVerifier>, CollabRuntimeFailure> {
if let Ok(client) = RelayLocatorHttpClient::new(endpoint, verifier.clone()) {
if let Ok(client) =
RelayLocatorHttpClient::new(&region.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(
&region.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<RelayLocatorHttpClient<Ed25519LocatorVerifier>, CollabRuntimeFailure> {
RelayLocatorHttpClient::new(endpoint, verifier)
RelayLocatorHttpClient::new(&region.locator_url, region.locator_verifier.clone())
.map_err(|_| CollabRuntimeFailure::RelayUnavailable)
}
fn parse_locator_keys(raw: &str) -> Result<Ed25519LocatorVerifier, ()> {
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<std::sync::Arc<PinnedRelayX25519Keys>, 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<PinnedRelayX25519Keys, CollabRuntimeFailure> {
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<LocalAdmission>,
) -> Result<VerifiedRelayRoute, CollabRuntimeFailure> {
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<LocalAdmission>,
) -> Result<VerifiedRelayRoute, CollabRuntimeFailure> {
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<String, CollabRuntimeFailure> {

View file

@ -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<Mutex<()>> = OnceLock::new();
pub(super) trait RelayBootstrapProvider: Send + Sync {
fn load(&self) -> Result<Arc<RelayBootstrap>, CollabRuntimeFailure>;
}
#[derive(Debug)]
pub(super) struct RelayBootstrap {
generation: u64,
signed_payload: Box<[u8]>,
regions: Vec<RelayBootstrapRegion>,
}
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<PinnedRelayX25519Keys>,
#[cfg(any(test, debug_assertions))]
pub(super) development_http: bool,
}
#[derive(Clone, Debug)]
pub(super) struct Ed25519LocatorVerifier {
keys: HashMap<String, VerifyingKey>,
}
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<Option<Arc<dyn RelayBootstrapProvider>>, 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<String, VerifyingKey>,
development_http: bool,
cache_path: PathBuf,
}
impl EnvironmentRelayBootstrapProvider {
fn new(endpoint: &str) -> Result<Self, BootstrapError> {
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<Arc<RelayBootstrap>, 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<reqwest::blocking::Response, BootstrapError> {
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<Arc<RelayBootstrap>, 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<String, VerifyingKey>,
now: u64,
development_http: bool,
require_current: bool,
) -> Result<RelayBootstrap, BootstrapError> {
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| &region.locator_keys)?;
validate_global_key_registry(&payload.regions, |region| &region.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<RelayBootstrapRegion, BootstrapError> {
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<BootstrapKey>) -> Result<Ed25519LocatorVerifier, BootstrapError> {
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<VerifyingKey, BootstrapError> {
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<BootstrapKey>) -> Result<PinnedRelayX25519Keys, BootstrapError> {
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<RelayRegion, BootstrapError> {
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<HashMap<String, VerifyingKey>, 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<String, VerifyingKey>) -> 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<String, VerifyingKey>) -> Result<(), BootstrapError> {
Ok(())
}
fn decode_fixed<const N: usize>(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<Vec<u8>, 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::<IpAddr>().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<String, BootstrapError> {
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<BootstrapRegion>,
}
#[derive(Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
struct BootstrapRegion {
region: String,
relay_url: String,
locator_url: String,
locator_keys: Vec<BootstrapKey>,
relay_x25519_keys: Vec<BootstrapKey>,
}
#[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<String>,
body: String,
}
fn read_cache(path: &Path, endpoint: &str) -> Result<Option<BootstrapCache>, 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;

View file

@ -0,0 +1 @@
{"version":1,"kid":"test-collab-root-v1","payload":"eyJ2ZXJzaW9uIjoxLCJnZW5lcmF0aW9uIjo0Miwibm90X2JlZm9yZV91bml4IjoxODkzNDU2MDAwLCJub3RfYWZ0ZXJfdW5peCI6MTg5NDA2MDgwMCwicmVnaW9ucyI6W3sicmVnaW9uIjoiY24iLCJyZWxheV91cmwiOiJ3c3M6Ly9yZWxheS5leGFtcGxlLmNuL3YxL3R1bm5lbCIsImxvY2F0b3JfdXJsIjoiaHR0cHM6Ly9sb2NhdG9yLmV4YW1wbGUuY24vdjEvbG9jYXRvciIsImxvY2F0b3Jfa2V5cyI6W3sia2lkIjoibG9jYXRvci1jbi0yMDMwLTAxIiwieCI6IktheTY0VUc4eXZDeUxocVUwMDBMeHpZZVVtMExfaExJbDVTOGt5S1diZGMifV0sInJlbGF5X3gyNTUxOV9rZXlzIjpbeyJraWQiOiJyZWxheS1jbi0yMDMwLTAxIiwieCI6IloxM1ZkTzEzaVRFTFBTNTJnZk41QzBac2R6c1ZJZjdQTmxkNVdEY2VwUzgifV19LHsicmVnaW9uIjoiZ2xvYmFsIiwicmVsYXlfdXJsIjoid3NzOi8vcmVsYXkuZXhhbXBsZS5jb20vdjEvdHVubmVsIiwibG9jYXRvcl91cmwiOiJodHRwczovL2xvY2F0b3IuZXhhbXBsZS5jb20vdjEvbG9jYXRvciIsImxvY2F0b3Jfa2V5cyI6W3sia2lkIjoibG9jYXRvci1nbG9iYWwtMjAzMC0wMSIsIngiOiJKVU81TF9FSlZSRkhhdHlEYWR0dDNKTTJaYUVaZU4yaFFFN2hCbXlwVlowIn1dLCJyZWxheV94MjU1MTlfa2V5cyI6W3sia2lkIjoicmVsYXktZ2xvYmFsLTIwMzAtMDEiLCJ4IjoiU1Q2Q19IUkdTbGttaUJkaVBTQlR4ZXVPTE1TcGlMVC00WG5zYXdFTlV4MCJ9XX1dfQ","signature":"zxYo5RkaGCxa5Otax4F4p_EU6zsPG4KvkrE8L-O_HHVRqXy4tgSt5GJW0UPjpJMYDGxEvPgXYaBtEPnxzA_MCg"}

View file

@ -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"}]}]}

View file

@ -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<u8> {
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<String, VerifyingKey> {
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);
}

View file

@ -0,0 +1,27 @@
use reqwest::Url;
use super::MAX_URL_BYTES;
pub(super) fn parse(value: &str, path: &str) -> Option<Url> {
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)
}

View file

@ -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<std::sync::Arc<RelayBootstrap>, 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<VerifiedRelayRoute, CollabRuntimeFailure> {
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]

View file

@ -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.