feat(desktop): load signed relay bootstrap
This commit is contained in:
parent
7639ac2c0f
commit
4b3d3334ba
1
Cargo.lock
generated
1
Cargo.lock
generated
|
|
@ -3961,6 +3961,7 @@ dependencies = [
|
|||
"rfd",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"skia-safe",
|
||||
"socket2 0.6.3",
|
||||
"static_assertions",
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(®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<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(®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<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(®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<RelayLocatorHttpClient<Ed25519LocatorVerifier>, 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<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> {
|
||||
|
|
|
|||
795
crates/op-host-desktop/src/collab_runtime/relay_bootstrap.rs
Normal file
795
crates/op-host-desktop/src/collab_runtime/relay_bootstrap.rs
Normal 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| ®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<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;
|
||||
|
|
@ -0,0 +1 @@
|
|||
{"version":1,"kid":"test-collab-root-v1","payload":"eyJ2ZXJzaW9uIjoxLCJnZW5lcmF0aW9uIjo0Miwibm90X2JlZm9yZV91bml4IjoxODkzNDU2MDAwLCJub3RfYWZ0ZXJfdW5peCI6MTg5NDA2MDgwMCwicmVnaW9ucyI6W3sicmVnaW9uIjoiY24iLCJyZWxheV91cmwiOiJ3c3M6Ly9yZWxheS5leGFtcGxlLmNuL3YxL3R1bm5lbCIsImxvY2F0b3JfdXJsIjoiaHR0cHM6Ly9sb2NhdG9yLmV4YW1wbGUuY24vdjEvbG9jYXRvciIsImxvY2F0b3Jfa2V5cyI6W3sia2lkIjoibG9jYXRvci1jbi0yMDMwLTAxIiwieCI6IktheTY0VUc4eXZDeUxocVUwMDBMeHpZZVVtMExfaExJbDVTOGt5S1diZGMifV0sInJlbGF5X3gyNTUxOV9rZXlzIjpbeyJraWQiOiJyZWxheS1jbi0yMDMwLTAxIiwieCI6IloxM1ZkTzEzaVRFTFBTNTJnZk41QzBac2R6c1ZJZjdQTmxkNVdEY2VwUzgifV19LHsicmVnaW9uIjoiZ2xvYmFsIiwicmVsYXlfdXJsIjoid3NzOi8vcmVsYXkuZXhhbXBsZS5jb20vdjEvdHVubmVsIiwibG9jYXRvcl91cmwiOiJodHRwczovL2xvY2F0b3IuZXhhbXBsZS5jb20vdjEvbG9jYXRvciIsImxvY2F0b3Jfa2V5cyI6W3sia2lkIjoibG9jYXRvci1nbG9iYWwtMjAzMC0wMSIsIngiOiJKVU81TF9FSlZSRkhhdHlEYWR0dDNKTTJaYUVaZU4yaFFFN2hCbXlwVlowIn1dLCJyZWxheV94MjU1MTlfa2V5cyI6W3sia2lkIjoicmVsYXktZ2xvYmFsLTIwMzAtMDEiLCJ4IjoiU1Q2Q19IUkdTbGttaUJkaVBTQlR4ZXVPTE1TcGlMVC00WG5zYXdFTlV4MCJ9XX1dfQ","signature":"zxYo5RkaGCxa5Otax4F4p_EU6zsPG4KvkrE8L-O_HHVRqXy4tgSt5GJW0UPjpJMYDGxEvPgXYaBtEPnxzA_MCg"}
|
||||
|
|
@ -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"}]}]}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in a new issue