diff --git a/Cargo.lock b/Cargo.lock index 350abe5dd..2a5255685 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3768,10 +3768,16 @@ dependencies = [ name = "op-collab-policy-file" version = "0.8.3" dependencies = [ + "base64", "blake3", + "ed25519-dalek", "libc", "op-auth-bridge", + "op-collab-relay-protocol", + "serde", + "serde_json", "tempfile", + "thiserror 1.0.69", ] [[package]] @@ -3825,6 +3831,7 @@ name = "op-collab-relay-locator-server" version = "0.8.3" dependencies = [ "axum", + "base64", "bytes", "ed25519-dalek", "hyper", @@ -3835,6 +3842,8 @@ dependencies = [ "op-collab-relay-control-plane", "op-collab-relay-protocol", "reqwest 0.12.28", + "serde_json", + "sha2", "socket2 0.6.3", "tempfile", "thiserror 1.0.69", @@ -3874,7 +3883,9 @@ dependencies = [ "op-collab-relay-protocol", "serde", "serde_json", + "sha2", "subtle", + "tempfile", "thiserror 1.0.69", "tokio", "tokio-tungstenite", diff --git a/crates/op-auth-bridge/src/collab_union_policy.rs b/crates/op-auth-bridge/src/collab_union_policy.rs index d0c2de1b1..5ad64251a 100644 --- a/crates/op-auth-bridge/src/collab_union_policy.rs +++ b/crates/op-auth-bridge/src/collab_union_policy.rs @@ -16,12 +16,43 @@ pub const COLLAB_UNION_POLICY_VERSION: u32 = 2; pub const MAX_COLLAB_UNION_POLICY_REGIONS: usize = 8; pub const MAX_COLLAB_UNION_POLICY_KEYS: usize = 24; pub const MAX_COLLAB_UNION_POLICY_LIFETIME_SECONDS: i64 = 7 * 24 * 60 * 60; -pub const COLLAB_UNION_POLICY_ROOT_X: &str = "5SVj-_jnJbuZlpDoD3M9x1eZAPDFLSq5jRb-c0xUh5A"; +pub const COLLAB_UNION_POLICY_LEGACY_ROOT_X: &str = "5SVj-_jnJbuZlpDoD3M9x1eZAPDFLSq5jRb-c0xUh5A"; +pub const COLLAB_UNION_POLICY_CURRENT_ROOT_X: &str = "DQJfLM6RZhfcHW52PKmzKNrubWGl0g5p3mBSKNsVOus"; +/// Compatibility alias for the original generation 1-3 policy root. +pub const COLLAB_UNION_POLICY_ROOT_X: &str = COLLAB_UNION_POLICY_LEGACY_ROOT_X; const POLICY_DOMAIN: &[u8] = b"openpencil/collab-union-policy/v2\0"; const MAX_REGION_ID_BYTES: usize = 32; const MAX_KEY_ID_BYTES: usize = 128; +const PINNED_POLICY_ROOTS: [PolicyRootSpec; 2] = [ + PolicyRootSpec { + public_key_x: COLLAB_UNION_POLICY_LEGACY_ROOT_X, + minimum_generation: 1, + maximum_generation: 3, + }, + PolicyRootSpec { + public_key_x: COLLAB_UNION_POLICY_CURRENT_ROOT_X, + minimum_generation: 4, + maximum_generation: 0, + }, +]; + +#[derive(Clone, Copy)] +struct PolicyRootSpec { + public_key_x: &'static str, + minimum_generation: u64, + /// Zero means no upper bound. + maximum_generation: u64, +} + +#[derive(Clone)] +struct PolicyRoot { + key: VerifyingKey, + minimum_generation: u64, + maximum_generation: u64, +} + /// A verified public-key union authorized by the pinned offline root. #[derive(Clone, PartialEq, Eq)] pub struct CollabUnionPolicy { @@ -42,23 +73,45 @@ impl CollabUnionPolicy { expected_issuer: &str, now_unix_seconds: u64, ) -> Result { - let root = decode_fixed::<32>(COLLAB_UNION_POLICY_ROOT_X) - .ok_or(CollabUnionPolicyError::InvalidSignature)?; - Self::from_json_with_root( + let roots = pinned_policy_roots()?; + Self::from_json_with_roots( body, maximum_body_bytes, expected_issuer, now_unix_seconds, - root, + &roots, ) } + #[cfg(test)] fn from_json_with_root( body: &[u8], maximum_body_bytes: usize, expected_issuer: &str, now_unix_seconds: u64, root: [u8; 32], + ) -> Result { + let root = VerifyingKey::from_bytes(&root) + .map_err(|_| CollabUnionPolicyError::InvalidSignature)?; + Self::from_json_with_roots( + body, + maximum_body_bytes, + expected_issuer, + now_unix_seconds, + &[PolicyRoot { + key: root, + minimum_generation: 1, + maximum_generation: 0, + }], + ) + } + + fn from_json_with_roots( + body: &[u8], + maximum_body_bytes: usize, + expected_issuer: &str, + now_unix_seconds: u64, + roots: &[PolicyRoot], ) -> Result { let maximum_body_bytes = maximum_body_bytes.min(HARD_MAX_COLLAB_JWKS_BYTES); if body.is_empty() || body.len() > maximum_body_bytes { @@ -75,10 +128,12 @@ impl CollabUnionPolicy { let signature = decode_fixed::<64>(&canonical.signature) .ok_or(CollabUnionPolicyError::InvalidSignature)?; - let root = VerifyingKey::from_bytes(&root) - .map_err(|_| CollabUnionPolicyError::InvalidSignature)?; - root.verify_strict(&message, &Signature::from_bytes(&signature)) - .map_err(|_| CollabUnionPolicyError::InvalidSignature)?; + verify_policy_signature( + canonical.unsigned.generation, + &message, + &Signature::from_bytes(&signature), + roots, + )?; let policy = Self { generation: canonical.unsigned.generation, @@ -165,6 +220,54 @@ impl CollabUnionPolicy { } } +fn pinned_policy_roots() -> Result, CollabUnionPolicyError> { + PINNED_POLICY_ROOTS + .iter() + .map(|spec| { + let bytes = decode_fixed::<32>(spec.public_key_x) + .ok_or(CollabUnionPolicyError::InvalidSignature)?; + let key = VerifyingKey::from_bytes(&bytes) + .map_err(|_| CollabUnionPolicyError::InvalidSignature)?; + if spec.minimum_generation == 0 + || (spec.maximum_generation != 0 + && spec.maximum_generation < spec.minimum_generation) + { + return Err(CollabUnionPolicyError::InvalidSignature); + } + Ok(PolicyRoot { + key, + minimum_generation: spec.minimum_generation, + maximum_generation: spec.maximum_generation, + }) + }) + .collect() +} + +fn verify_policy_signature( + generation: u64, + message: &[u8], + signature: &Signature, + roots: &[PolicyRoot], +) -> Result<(), CollabUnionPolicyError> { + let mut matching_roots = 0_u8; + let mut authorized_roots = 0_u8; + for root in roots { + if root.key.verify_strict(message, signature).is_err() { + continue; + } + matching_roots = matching_roots.saturating_add(1); + if generation >= root.minimum_generation + && (root.maximum_generation == 0 || generation <= root.maximum_generation) + { + authorized_roots = authorized_roots.saturating_add(1); + } + } + if matching_roots != 1 || authorized_roots != 1 { + return Err(CollabUnionPolicyError::InvalidSignature); + } + Ok(()) +} + #[cfg(test)] fn canonical_message_for_test( body: &[u8], diff --git a/crates/op-auth-bridge/src/collab_union_policy_tests.rs b/crates/op-auth-bridge/src/collab_union_policy_tests.rs index ae99ec992..1001d31a7 100644 --- a/crates/op-auth-bridge/src/collab_union_policy_tests.rs +++ b/crates/op-auth-bridge/src/collab_union_policy_tests.rs @@ -10,6 +10,8 @@ use super::*; const ISSUER: &str = "https://collab.example.com"; const NOW: u64 = 1_800_000_000; const GO_V2_FIXTURE: &str = include_str!("../tests/fixtures/zseven-sso-go-union-policy-v2.json"); +const GO_V2_GENERATION_4_FIXTURE: &[u8] = + include_bytes!("../tests/fixtures/zseven-sso-go-union-policy-v2-generation-4.json"); #[derive(Deserialize)] #[serde(deny_unknown_fields)] @@ -97,6 +99,16 @@ fn parse_test_policy(value: Value, now: u64) -> Result Result { + let body = sign_value(value, signing_key); + CollabUnionPolicy::from_json_with_roots(&body, 64 * 1024, ISSUER, now, roots) +} + fn parse_test_body( body: &[u8], maximum_body_bytes: usize, @@ -176,15 +188,141 @@ fn verifies_the_frozen_go_production_root_fixture() { } #[test] -fn production_v2_policy_root_is_pinned() { - let root = decode_fixed::<32>(COLLAB_UNION_POLICY_ROOT_X).unwrap(); - let mut spki = vec![ - 0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00, - ]; - spki.extend_from_slice(&root); +fn verifies_the_frozen_go_generation_four_current_root_fixture() { + let fixture = GO_V2_GENERATION_4_FIXTURE + .strip_suffix(b"\n") + .unwrap_or(GO_V2_GENERATION_4_FIXTURE); assert_eq!( - format!("{:x}", Sha256::digest(&spki)), - "53700c011a688b8077850f1330567c265f97cd5e34c9b67aa6695a3fe8afb20c" + format!("{:x}", Sha256::digest(fixture)), + "b02f32f7827b7d7056c97997c0f953f44ad3b18928676e9a46a8192dd059ee93" + ); + let wire: PolicyWire = serde_json::from_slice(fixture).unwrap(); + assert_eq!(wire.generation, 4); + let issuer = wire.issuer.clone(); + let now = u64::try_from(wire.not_before_unix).unwrap() + 1; + let policy = CollabUnionPolicy::from_json(fixture, 64 * 1024, &issuer, now).unwrap(); + assert_eq!(policy.generation(), 4); + assert_eq!(policy.issuer(), "https://sso.zseven.cn"); + assert_eq!(policy.recovery_epoch("cn"), Some(1)); + assert_eq!(policy.recovery_epoch("global"), Some(1)); +} + +#[test] +fn production_v2_policy_root_is_pinned() { + for (encoded, expected) in [ + ( + COLLAB_UNION_POLICY_LEGACY_ROOT_X, + "53700c011a688b8077850f1330567c265f97cd5e34c9b67aa6695a3fe8afb20c", + ), + ( + COLLAB_UNION_POLICY_CURRENT_ROOT_X, + "ee695282bf7120eef385743c59cd9d8c900a182f7c518f0df0ca21891cf1809e", + ), + ] { + let root = decode_fixed::<32>(encoded).unwrap(); + let mut spki = vec![ + 0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00, + ]; + spki.extend_from_slice(&root); + assert_eq!(format!("{:x}", Sha256::digest(&spki)), expected); + } + assert_eq!( + COLLAB_UNION_POLICY_ROOT_X, + COLLAB_UNION_POLICY_LEGACY_ROOT_X + ); + assert_eq!(PINNED_POLICY_ROOTS.len(), 2); + assert_eq!(PINNED_POLICY_ROOTS[0].minimum_generation, 1); + assert_eq!(PINNED_POLICY_ROOTS[0].maximum_generation, 3); + assert_eq!(PINNED_POLICY_ROOTS[1].minimum_generation, 4); + assert_eq!(PINNED_POLICY_ROOTS[1].maximum_generation, 0); +} + +#[test] +fn dual_root_generation_fence_accepts_only_the_authorized_signer() { + let legacy = SigningKey::from_bytes(&[0x31; 32]); + let current = SigningKey::from_bytes(&[0x32; 32]); + let roots = [ + PolicyRoot { + key: legacy.verifying_key(), + minimum_generation: 1, + maximum_generation: 3, + }, + PolicyRoot { + key: current.verifying_key(), + minimum_generation: 4, + maximum_generation: 0, + }, + ]; + + for (signing_key, generation, accepted) in [ + (&legacy, 3, true), + (&legacy, 4, false), + (¤t, 3, false), + (¤t, 4, true), + ] { + let mut value = policy_fixture(); + value["generation"] = json!(generation); + let parsed = parse_test_policy_with_roots(value, NOW, signing_key, &roots); + assert_eq!(parsed.is_ok(), accepted, "generation {generation}"); + } +} + +#[test] +fn dual_root_verification_rejects_unknown_tampered_and_ambiguous_signatures() { + let legacy = SigningKey::from_bytes(&[0x31; 32]); + let current = SigningKey::from_bytes(&[0x32; 32]); + let unknown = SigningKey::from_bytes(&[0x33; 32]); + let roots = [ + PolicyRoot { + key: legacy.verifying_key(), + minimum_generation: 1, + maximum_generation: 3, + }, + PolicyRoot { + key: current.verifying_key(), + minimum_generation: 4, + maximum_generation: 0, + }, + ]; + + let mut generation_four = policy_fixture(); + generation_four["generation"] = json!(4); + assert_eq!( + parse_test_policy_with_roots(generation_four.clone(), NOW, &unknown, &roots), + Err(CollabUnionPolicyError::InvalidSignature) + ); + + let signed = sign_value(generation_four, ¤t); + let mut tampered: Value = serde_json::from_slice(&signed).unwrap(); + tampered["required_regions"][0]["recovery_epoch"] = json!(99); + assert_eq!( + CollabUnionPolicy::from_json_with_roots( + &serde_json::to_vec(&tampered).unwrap(), + 64 * 1024, + ISSUER, + NOW, + &roots, + ), + Err(CollabUnionPolicyError::InvalidSignature) + ); + + let ambiguous = [ + PolicyRoot { + key: legacy.verifying_key(), + minimum_generation: 1, + maximum_generation: 3, + }, + PolicyRoot { + key: legacy.verifying_key(), + minimum_generation: 4, + maximum_generation: 0, + }, + ]; + let mut generation_three = policy_fixture(); + generation_three["generation"] = json!(3); + assert_eq!( + parse_test_policy_with_roots(generation_three, NOW, &legacy, &ambiguous), + Err(CollabUnionPolicyError::InvalidSignature) ); } diff --git a/crates/op-auth-bridge/src/lib.rs b/crates/op-auth-bridge/src/lib.rs index 3a10488ae..23a0272e4 100644 --- a/crates/op-auth-bridge/src/lib.rs +++ b/crates/op-auth-bridge/src/lib.rs @@ -83,9 +83,9 @@ pub use collab_ticket::{ }; pub use collab_ticket_error::{CollabTicketError, CollabTicketProviderErrorCode}; pub use collab_union_policy::{ - CollabUnionPolicy, COLLAB_UNION_POLICY_ROOT_X, COLLAB_UNION_POLICY_VERSION, - MAX_COLLAB_UNION_POLICY_KEYS, MAX_COLLAB_UNION_POLICY_LIFETIME_SECONDS, - MAX_COLLAB_UNION_POLICY_REGIONS, + CollabUnionPolicy, COLLAB_UNION_POLICY_CURRENT_ROOT_X, COLLAB_UNION_POLICY_LEGACY_ROOT_X, + COLLAB_UNION_POLICY_ROOT_X, COLLAB_UNION_POLICY_VERSION, MAX_COLLAB_UNION_POLICY_KEYS, + MAX_COLLAB_UNION_POLICY_LIFETIME_SECONDS, MAX_COLLAB_UNION_POLICY_REGIONS, }; pub use collab_verifier::{ CollabTicketVerifier, MAX_COLLAB_JWS_CLAIMS_BYTES, MAX_COLLAB_JWS_HEADER_BYTES, diff --git a/crates/op-auth-bridge/tests/fixtures/zseven-sso-go-union-policy-v2-generation-4.json b/crates/op-auth-bridge/tests/fixtures/zseven-sso-go-union-policy-v2-generation-4.json new file mode 100644 index 000000000..7abf56050 --- /dev/null +++ b/crates/op-auth-bridge/tests/fixtures/zseven-sso-go-union-policy-v2-generation-4.json @@ -0,0 +1 @@ +{"version":2,"generation":4,"issuer":"https://sso.zseven.cn","not_before_unix":1786259065,"not_after_unix":1786863865,"required_regions":[{"region":"cn","recovery_epoch":1},{"region":"global","recovery_epoch":1}],"keys":[{"region":"cn","kid":"cn-active-20260731-a31de19d88e66a07","x":"V2AlFxA-FgO4SrUiUue-ahXD4_-fsgf4Y7c1eDwKdKo","published_at_unix":1785494306,"activated_at_unix":1785494721,"retired_at_unix":0,"not_after_unix":0},{"region":"cn","kid":"cn-next-20260731-ef9248b8c85edea4","x":"A-nmTfuSd-LYdfOxlonRhFknFltQkmKAGxAfKYq-MTw","published_at_unix":1785494306,"activated_at_unix":0,"retired_at_unix":0,"not_after_unix":0},{"region":"global","kid":"global-active-20260731-991cdb732addbdfa","x":"re3IwZEOMueZwh_uS1xOSP3Gw1ZwZvRDirnH3Ls5PcU","published_at_unix":1785494346,"activated_at_unix":1785494721,"retired_at_unix":0,"not_after_unix":0},{"region":"global","kid":"global-next-20260731-e332d3f0a8619405","x":"DNs68JOqnejXm8pSRfrYA-snKyPEfDjBegtweBOmqU4","published_at_unix":1785494346,"activated_at_unix":0,"retired_at_unix":0,"not_after_unix":0}],"signature":"K9VlW4yF19OmfmIo7gaYSoOt0aeZ0xyveEvl5AtplFgLbyIMSyPkN-CAKJ0JWaDi2N6quNFu0j6CjnGKicCRCw"} diff --git a/crates/op-collab-host/src/runtime/relay_bootstrap.rs b/crates/op-collab-host/src/runtime/relay_bootstrap.rs index 12dfcb7fb..b1672bb9d 100644 --- a/crates/op-collab-host/src/runtime/relay_bootstrap.rs +++ b/crates/op-collab-host/src/runtime/relay_bootstrap.rs @@ -33,14 +33,16 @@ mod bootstrap_cache; #[path = "relay_bootstrap_select.rs"] mod bootstrap_select; +#[path = "relay_bootstrap_roots.rs"] +mod bootstrap_roots; + use bootstrap_cache::{endpoint_cache_file, read_cache, write_cache, BootstrapCache}; +use bootstrap_roots::{add_development_roots, builtin_roots, root_authorizes_generation}; pub(super) use bootstrap_select::bootstrap_provider; 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"; @@ -49,16 +51,10 @@ const BOOTSTRAP_CONTEXT: &[u8] = b"openpencil/op-hub/collaboration-bootstrap/v1\ 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-union-root-v2"; -const BUILTIN_ROOT_X: &str = "5SVj-_jnJbuZlpDoD3M9x1eZAPDFLSq5jRb-c0xUh5A"; 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; @@ -372,6 +368,9 @@ fn verify_bootstrap( return Err(BootstrapError::InvalidPayload); } validate_payload(&payload, now, require_current)?; + if !root_authorizes_generation(&envelope.kid, payload.generation) { + return Err(BootstrapError::InvalidSignature); + } let mut seen_regions = HashSet::new(); let mut regions = Vec::with_capacity(payload.regions.len()); for wire in payload.regions { @@ -589,39 +588,6 @@ fn reject_rollback( Ok(()) } -fn builtin_roots() -> Result, BootstrapError> { - let bytes = decode_fixed::<32>(BUILTIN_ROOT_X)?; - let key = canonical_ed25519_key(bytes, BootstrapError::InvalidRoot)?; - Ok(HashMap::from([(BUILTIN_ROOT_KID.to_owned(), key)])) -} - -#[cfg(any(test, debug_assertions))] -fn add_development_roots(roots: &mut HashMap) -> Result<(), BootstrapError> { - let Some(raw) = std::env::var(BOOTSTRAP_DEV_ROOT_KEYS_ENV).ok() else { - return Ok(()); - }; - if raw.is_empty() || raw.len() > MAX_ENV_BYTES { - return Err(BootstrapError::InvalidRoot); - } - for entry in raw.split([',', ';']) { - let (kid, encoded) = entry.split_once('=').ok_or(BootstrapError::InvalidRoot)?; - if !valid_key_id(kid) || roots.len() >= MAX_ROOT_KEYS { - return Err(BootstrapError::InvalidRoot); - } - let bytes = decode_fixed::<32>(encoded)?; - let key = canonical_ed25519_key(bytes, BootstrapError::InvalidRoot)?; - if roots.insert(kid.to_owned(), key).is_some() { - return Err(BootstrapError::InvalidRoot); - } - } - Ok(()) -} - -#[cfg(not(any(test, debug_assertions)))] -fn add_development_roots(_roots: &mut HashMap) -> Result<(), BootstrapError> { - Ok(()) -} - fn decode_fixed(encoded: &str) -> Result<[u8; N], BootstrapError> { if encoded.is_empty() || encoded.contains('=') { return Err(BootstrapError::InvalidBase64); @@ -791,3 +757,7 @@ enum BootstrapError { #[cfg(test)] #[path = "relay_bootstrap_tests.rs"] mod tests; + +#[cfg(test)] +#[path = "relay_bootstrap_root_tests.rs"] +mod root_tests; diff --git a/crates/op-collab-host/src/runtime/relay_bootstrap_root_tests.rs b/crates/op-collab-host/src/runtime/relay_bootstrap_root_tests.rs new file mode 100644 index 000000000..b160d3373 --- /dev/null +++ b/crates/op-collab-host/src/runtime/relay_bootstrap_root_tests.rs @@ -0,0 +1,260 @@ +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use base64::Engine as _; +use ed25519_dalek::{Signer as _, SigningKey}; +use sha2::{Digest as _, Sha256}; + +use super::bootstrap_roots::{ + add_development_roots_for_build, BUILTIN_ROOTS, CURRENT_BUILTIN_ROOT_KID, + CURRENT_BUILTIN_ROOT_X, LEGACY_BUILTIN_ROOT_KID, LEGACY_BUILTIN_ROOT_X, +}; +use super::*; + +const NOW: u64 = 1_900_000_000; +const PRODUCTION_GENERATION_3_ENVELOPE_BASE64: &[u8] = + include_bytes!("relay_bootstrap_testdata/op-hub-production-generation-3-envelope.base64"); +const PRODUCTION_GENERATION_3_NOW: u64 = 1_786_259_263; +const PRODUCTION_GENERATION_3_SHA256: &str = + "bbe68bfd2486a3ecf89335d825b8017bfc34c064512e5b42b182e8452913d63b"; + +fn bootstrap_key(kid: &str, bytes: [u8; 32]) -> BootstrapKey { + BootstrapKey { + kid: kid.to_owned(), + x: URL_SAFE_NO_PAD.encode(bytes), + } +} + +fn payload(generation: u64) -> BootstrapPayload { + let locator_cn = SigningKey::from_bytes(&[0x41; 32]); + let locator_global = SigningKey::from_bytes(&[0x42; 32]); + let relay_cn = DeviceStaticKey::from_private([0x43; 32]).unwrap(); + let relay_global = DeviceStaticKey::from_private([0x44; 32]).unwrap(); + BootstrapPayload { + version: BOOTSTRAP_VERSION, + generation, + 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![bootstrap_key( + "locator_cn_1", + locator_cn.verifying_key().to_bytes(), + )], + relay_x25519_keys: vec![bootstrap_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![bootstrap_key( + "locator_global_1", + locator_global.verifying_key().to_bytes(), + )], + relay_x25519_keys: vec![bootstrap_key( + "relay_global_1", + *relay_global.public_key(), + )], + }, + ], + } +} + +fn signed_envelope(signing: &SigningKey, kid: &str, generation: u64) -> Vec { + let payload = serde_json::to_vec(&payload(generation)).unwrap(); + let mut signing_bytes = BOOTSTRAP_CONTEXT.to_vec(); + signing_bytes.extend_from_slice(&payload); + serde_json::to_vec(&BootstrapEnvelope { + version: BOOTSTRAP_VERSION, + kid: kid.to_owned(), + payload: URL_SAFE_NO_PAD.encode(payload), + signature: URL_SAFE_NO_PAD.encode(signing.sign(&signing_bytes).to_bytes()), + }) + .unwrap() +} + +#[test] +fn builtin_bootstrap_roots_pin_the_legacy_and_current_keys() { + assert_eq!(BUILTIN_ROOTS.len(), 2); + let roots = builtin_roots().unwrap(); + assert_eq!(roots.len(), 2); + for (kid, encoded, expected_spki_sha256) in [ + ( + LEGACY_BUILTIN_ROOT_KID, + LEGACY_BUILTIN_ROOT_X, + "53700c011a688b8077850f1330567c265f97cd5e34c9b67aa6695a3fe8afb20c", + ), + ( + CURRENT_BUILTIN_ROOT_KID, + CURRENT_BUILTIN_ROOT_X, + "7100466d7d118d6bf8f6f027febaae569f880690d223d2d794d7638b79252f41", + ), + ] { + let expected = decode_fixed::<32>(encoded).unwrap(); + assert_eq!(roots.get(kid).unwrap().to_bytes(), expected); + let mut spki = vec![ + 0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00, + ]; + spki.extend_from_slice(&expected); + assert_eq!(format!("{:x}", Sha256::digest(&spki)), expected_spki_sha256); + } +} + +#[test] +fn hsm_signed_production_generation_three_fixture_verifies_byte_exactly() { + let encoded = PRODUCTION_GENERATION_3_ENVELOPE_BASE64 + .strip_suffix(b"\n") + .expect("the base64 fixture must have one repository line terminator"); + assert_eq!(encoded.len(), 2_280); + assert!(!encoded.contains(&b'\r')); + assert!(!encoded.contains(&b'\n')); + + let body = base64::engine::general_purpose::STANDARD + .decode(encoded) + .unwrap(); + assert_eq!(body.len(), 1_709); + assert_eq!(body.last(), Some(&b'}')); + assert!(!body.ends_with(b"\n")); + assert_eq!( + format!("{:x}", Sha256::digest(&body)), + PRODUCTION_GENERATION_3_SHA256 + ); + + let roots = builtin_roots().unwrap(); + let verified = + verify_bootstrap(&body, &roots, PRODUCTION_GENERATION_3_NOW, false, true).unwrap(); + assert_eq!(verified.generation, 3); + + let cn = verified.region(RelayRegion::Cn).unwrap(); + assert_eq!( + cn.relay_endpoint, + RelayEndpoint::parse("wss://op.zseven.cn/v1/tunnel").unwrap() + ); + assert_eq!(cn.locator_url, "https://op.zseven.cn/v1/locator"); + + let global = verified.region(RelayRegion::Global).unwrap(); + assert_eq!( + global.relay_endpoint, + RelayEndpoint::parse("wss://op.zseven.tech/v1/tunnel").unwrap() + ); + assert_eq!(global.locator_url, "https://op.zseven.tech/v1/locator"); + + let mut trailing_lf = body; + trailing_lf.push(b'\n'); + assert_eq!( + verify_bootstrap( + &trailing_lf, + &roots, + PRODUCTION_GENERATION_3_NOW, + false, + true, + ) + .unwrap_err(), + BootstrapError::InvalidResponse + ); +} + +#[test] +fn envelope_kid_selects_exactly_one_generation_authorized_root() { + let legacy = SigningKey::from_bytes(&[0x51; 32]); + let current = SigningKey::from_bytes(&[0x52; 32]); + let roots = HashMap::from([ + (LEGACY_BUILTIN_ROOT_KID.to_owned(), legacy.verifying_key()), + (CURRENT_BUILTIN_ROOT_KID.to_owned(), current.verifying_key()), + ]); + + for (signing, kid, generation) in [ + (&legacy, LEGACY_BUILTIN_ROOT_KID, 2), + (¤t, CURRENT_BUILTIN_ROOT_KID, 3), + ] { + assert!(verify_bootstrap( + &signed_envelope(signing, kid, generation), + &roots, + NOW, + false, + true, + ) + .is_ok()); + } + + assert_eq!( + verify_bootstrap( + &signed_envelope(¤t, LEGACY_BUILTIN_ROOT_KID, 2), + &roots, + NOW, + false, + true, + ) + .unwrap_err(), + BootstrapError::InvalidSignature + ); + + for (signing, kid, generation) in [ + (&legacy, LEGACY_BUILTIN_ROOT_KID, 3), + (¤t, CURRENT_BUILTIN_ROOT_KID, 2), + ] { + assert_eq!( + verify_bootstrap( + &signed_envelope(signing, kid, generation), + &roots, + NOW, + false, + true, + ) + .unwrap_err(), + BootstrapError::InvalidSignature + ); + } +} + +#[test] +fn bootstrap_rejects_unknown_root_and_tampered_payload() { + let current = SigningKey::from_bytes(&[0x52; 32]); + let roots = HashMap::from([(CURRENT_BUILTIN_ROOT_KID.to_owned(), current.verifying_key())]); + + assert_eq!( + verify_bootstrap( + &signed_envelope(¤t, "unknown-bootstrap-root", 3), + &roots, + NOW, + false, + true, + ) + .unwrap_err(), + BootstrapError::UnknownRoot + ); + + let body = signed_envelope(¤t, CURRENT_BUILTIN_ROOT_KID, 3); + let mut envelope: BootstrapEnvelope = serde_json::from_slice(&body).unwrap(); + let mut tampered: BootstrapPayload = + serde_json::from_slice(&decode_bounded(&envelope.payload, MAX_PAYLOAD_BYTES).unwrap()) + .unwrap(); + tampered.generation += 1; + envelope.payload = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&tampered).unwrap()); + let tampered = serde_json::to_vec(&envelope).unwrap(); + assert_eq!( + verify_bootstrap(&tampered, &roots, NOW, false, true).unwrap_err(), + BootstrapError::InvalidSignature + ); +} + +#[test] +fn release_profile_ignores_environment_root_material() { + let injected = SigningKey::from_bytes(&[0x61; 32]); + let raw = format!( + "injected_root={}", + URL_SAFE_NO_PAD.encode(injected.verifying_key().to_bytes()) + ); + let mut release_roots = builtin_roots().unwrap(); + add_development_roots_for_build(&mut release_roots, false, Some(&raw)).unwrap(); + assert_eq!(release_roots.len(), BUILTIN_ROOTS.len()); + assert!(!release_roots.contains_key("injected_root")); + + let mut debug_roots = builtin_roots().unwrap(); + add_development_roots_for_build(&mut debug_roots, true, Some(&raw)).unwrap(); + assert_eq!( + debug_roots.get("injected_root").unwrap(), + &injected.verifying_key() + ); +} diff --git a/crates/op-collab-host/src/runtime/relay_bootstrap_roots.rs b/crates/op-collab-host/src/runtime/relay_bootstrap_roots.rs new file mode 100644 index 000000000..18474a51b --- /dev/null +++ b/crates/op-collab-host/src/runtime/relay_bootstrap_roots.rs @@ -0,0 +1,85 @@ +use std::collections::HashMap; + +use ed25519_dalek::VerifyingKey; + +use super::{canonical_ed25519_key, decode_fixed, valid_key_id, BootstrapError}; + +pub(super) const LEGACY_BUILTIN_ROOT_KID: &str = "openpencil-collab-union-root-v2"; +pub(super) const LEGACY_BUILTIN_ROOT_X: &str = "5SVj-_jnJbuZlpDoD3M9x1eZAPDFLSq5jRb-c0xUh5A"; +pub(super) const CURRENT_BUILTIN_ROOT_KID: &str = "openpencil-collab-bootstrap-root-v2"; +pub(super) const CURRENT_BUILTIN_ROOT_X: &str = "2pPo4zN_Az7leTslTYWUkO-hyNjd_hZx83f6h_vUGAY"; +const LEGACY_BUILTIN_ROOT_MAX_GENERATION: u64 = 2; +const CURRENT_BUILTIN_ROOT_MIN_GENERATION: u64 = 3; + +pub(super) const BUILTIN_ROOTS: [(&str, &str); 2] = [ + (LEGACY_BUILTIN_ROOT_KID, LEGACY_BUILTIN_ROOT_X), + (CURRENT_BUILTIN_ROOT_KID, CURRENT_BUILTIN_ROOT_X), +]; + +#[cfg(any(test, debug_assertions))] +const BOOTSTRAP_DEV_ROOT_KEYS_ENV: &str = "OPENPENCIL_COLLAB_BOOTSTRAP_DEV_ROOT_KEYS"; +const MAX_ENV_BYTES: usize = 8 * 1024; +const MAX_ROOT_KEYS: usize = 8; + +pub(super) fn builtin_roots() -> Result, BootstrapError> { + let mut roots = HashMap::with_capacity(BUILTIN_ROOTS.len()); + for (kid, encoded) in BUILTIN_ROOTS { + 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(roots) +} + +pub(super) fn root_authorizes_generation(kid: &str, generation: u64) -> bool { + match kid { + LEGACY_BUILTIN_ROOT_KID => (1..=LEGACY_BUILTIN_ROOT_MAX_GENERATION).contains(&generation), + CURRENT_BUILTIN_ROOT_KID => generation >= CURRENT_BUILTIN_ROOT_MIN_GENERATION, + _ => true, + } +} + +#[cfg(any(test, debug_assertions))] +pub(super) fn add_development_roots( + roots: &mut HashMap, +) -> Result<(), BootstrapError> { + let raw = std::env::var(BOOTSTRAP_DEV_ROOT_KEYS_ENV).ok(); + add_development_roots_for_build(roots, true, raw.as_deref()) +} + +#[cfg(not(any(test, debug_assertions)))] +pub(super) fn add_development_roots( + roots: &mut HashMap, +) -> Result<(), BootstrapError> { + add_development_roots_for_build(roots, false, None) +} + +pub(super) fn add_development_roots_for_build( + roots: &mut HashMap, + development_build: bool, + raw: Option<&str>, +) -> Result<(), BootstrapError> { + if !development_build { + return Ok(()); + } + let Some(raw) = raw 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(()) +} diff --git a/crates/op-collab-host/src/runtime/relay_bootstrap_testdata/op-hub-production-generation-3-envelope.base64 b/crates/op-collab-host/src/runtime/relay_bootstrap_testdata/op-hub-production-generation-3-envelope.base64 new file mode 100644 index 000000000..e3c399960 --- /dev/null +++ b/crates/op-collab-host/src/runtime/relay_bootstrap_testdata/op-hub-production-generation-3-envelope.base64 @@ -0,0 +1 @@ +eyJ2ZXJzaW9uIjoxLCJraWQiOiJvcGVucGVuY2lsLWNvbGxhYi1ib290c3RyYXAtcm9vdC12MiIsInBheWxvYWQiOiJleUoyWlhKemFXOXVJam94TENKblpXNWxjbUYwYVc5dUlqb3pMQ0p1YjNSZlltVm1iM0psWDNWdWFYZ2lPakUzT0RZeU5Ua3lNRE1zSW01dmRGOWhablJsY2w5MWJtbDRJam94TnpnMk9EWTBNREF6TENKeVpXZHBiMjV6SWpwYmV5SnlaV2RwYjI0aU9pSmpiaUlzSW5KbGJHRjVYM1Z5YkNJNkluZHpjem92TDI5d0xucHpaWFpsYmk1amJpOTJNUzkwZFc1dVpXd2lMQ0pzYjJOaGRHOXlYM1Z5YkNJNkltaDBkSEJ6T2k4dmIzQXVlbk5sZG1WdUxtTnVMM1l4TDJ4dlkyRjBiM0lpTENKc2IyTmhkRzl5WDJ0bGVYTWlPbHQ3SW10cFpDSTZJbXh2WTJGMGIzSXRZMjR0WVdOMGFYWmxMVEl3TWpZd056TXhMVEl4TURRd05UVXlPR0ZsWmpZNU9Ua2lMQ0o0SWpvaVJGaFlTRmR5UlZSSlZFbzRObGhCWkZkS2RIVk5lRzVqVjBaUFpWbGlZMnhFWW1JMU1uZFRTM0Z5V1NKOUxIc2lhMmxrSWpvaWJHOWpZWFJ2Y2kxamJpMXVaWGgwTFRJd01qWXdOek14TFRkbU9HVmtZamMxWXpjNU16RXlOak1pTENKNElqb2liRTVOWkRGbGFXTkVUM05HT0RsbldUWlNTREZJV1hwVFgwczNlSFpyZFZGV2FrdFhiUzF1ZVRSSlp5SjlYU3dpY21Wc1lYbGZlREkxTlRFNVgydGxlWE1pT2x0N0ltdHBaQ0k2SW5KbGJHRjVMV051TFdFdE1qZzJNakZpT1dRNFpXRm1JaXdpZUNJNkluRk1PVlp1WTFKWVkxUXdlVE0yUTJwaFoxVmpTbTVEYkdKemVWUk1PRjk1U1dWUFZUQnNaVVpEVVZraWZTeDdJbXRwWkNJNkluSmxiR0Y1TFdOdUxXNHRZak0wTjJRek5EVXhOamd4SWl3aWVDSTZJbkJPTFV4NVNEbEtjRlZKYzJSeFgxVldaVTFGVGtKeVpUWmhRMWd6WldwWmJVd3RTVEpOTlhCb1Iwa2lmVjE5TEhzaWNtVm5hVzl1SWpvaVoyeHZZbUZzSWl3aWNtVnNZWGxmZFhKc0lqb2lkM056T2k4dmIzQXVlbk5sZG1WdUxuUmxZMmd2ZGpFdmRIVnVibVZzSWl3aWJHOWpZWFJ2Y2w5MWNtd2lPaUpvZEhSd2N6b3ZMMjl3TG5welpYWmxiaTUwWldOb0wzWXhMMnh2WTJGMGIzSWlMQ0pzYjJOaGRHOXlYMnRsZVhNaU9sdDdJbXRwWkNJNklteHZZMkYwYjNJdFoyeHZZbUZzTFdGamRHbDJaUzB5TURJMk1EY3pNUzA1TlRJeE5UTTJOVEZrTXpVM05UUTFJaXdpZUNJNkltdGtSMk5EUWxRelVGazBUMFJ1WDFSYVJIWkJNbkJ0YkUwd1RsWlZWVWcyUTAxb1FuSmlRemcwTkZVaWZTeDdJbXRwWkNJNklteHZZMkYwYjNJdFoyeHZZbUZzTFc1bGVIUXRNakF5TmpBM016RXRaakpsWkRRMlpHTmtPR1F4TmpjNU1TSXNJbmdpT2lJdFptaHFkazVVVFUwelNIUlNjMTk1WkZrd05XUnJVWGsyZVZkaVVsbE1iVmhSVFdWVFIweFlORmgzSW4xZExDSnlaV3hoZVY5NE1qVTFNVGxmYTJWNWN5STZXM3NpYTJsa0lqb2ljbVZzWVhrdFoyd3RZUzB6TUdGa1lXUTNaREJtWVdRaUxDSjRJam9pYkVwbVluUTJSbmhaU1RGeGJEWkdOSGhMYkdoYWQxZzVkVWhOWlY5eVZWZGhhekoyWTA4M2NHSXhOQ0o5TEhzaWEybGtJam9pY21Wc1lYa3RaMnd0YmkwMk5XVmhNemswTmpRd01XRWlMQ0o0SWpvaWR6ZGxWbTl3VVdaRlpGOHpXa2hqVmtoaVdsZFRUVkV3WkZsWFEzbzVPVWc0YlZWRVZHbE9TVWR1T0NKOVhYMWRmUSIsInNpZ25hdHVyZSI6IlM1Qy1kYkc1RmR5eWZod2hBNjYycF9DN0cyUXA5ZlRHaEZURzFSbFRfaDJFWjlWQ29tRC1aYmJNS1JnSThtVDB2SWZkVXNqbDVtWTRWVHoxQlVMdEJBIn0= diff --git a/crates/op-collab-policy-file/Cargo.toml b/crates/op-collab-policy-file/Cargo.toml index 892228737..207f4f43d 100644 --- a/crates/op-collab-policy-file/Cargo.toml +++ b/crates/op-collab-policy-file/Cargo.toml @@ -8,8 +8,14 @@ repository.workspace = true description = "Bounded no-follow signed-policy file source for OpenPencil collaboration services" [dependencies] +base64 = "0.22" blake3 = "1.5" +ed25519-dalek = { version = "2.2", default-features = false, features = ["std"] } op-auth-bridge = { path = "../op-auth-bridge" } +op-collab-relay-protocol = { path = "../op-collab-relay-protocol" } +serde.workspace = true +serde_json.workspace = true +thiserror.workspace = true [target.'cfg(unix)'.dependencies] libc = "0.2" diff --git a/crates/op-collab-policy-file/src/lib.rs b/crates/op-collab-policy-file/src/lib.rs index 3ef48c9ae..4a926fd98 100644 --- a/crates/op-collab-policy-file/src/lib.rs +++ b/crates/op-collab-policy-file/src/lib.rs @@ -13,6 +13,13 @@ use op_auth_bridge::{ CollabVerifierConfig, }; +mod pinned_locator_keys; + +pub use pinned_locator_keys::{ + PinnedEd25519LocatorVerifier, PinnedVerifierError, MAX_PINNED_VERIFIER_KEYS, + MAX_PINNED_VERIFIER_KEY_FILE_BYTES, PINNED_VERIFIER_KEY_FILE_VERSION, +}; + const FILE_ETAG_CONTEXT: &str = "openpencil/op-collab-policy-file/pinned-policy-file-etag/v1"; /// Typed rejection reasons for a policy file used as a trust root. diff --git a/crates/op-collab-relay-server/src/pinned_verifiers.rs b/crates/op-collab-policy-file/src/pinned_locator_keys.rs similarity index 99% rename from crates/op-collab-relay-server/src/pinned_verifiers.rs rename to crates/op-collab-policy-file/src/pinned_locator_keys.rs index de63afdd6..1a5778d54 100644 --- a/crates/op-collab-relay-server/src/pinned_verifiers.rs +++ b/crates/op-collab-policy-file/src/pinned_locator_keys.rs @@ -3,10 +3,11 @@ use std::{fmt, path::Path, sync::Arc}; use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; use ed25519_dalek::{Signature, VerifyingKey}; use op_auth_bridge::CollabJwksFetchError; -use op_collab_policy_file::read_bounded_regular_file; use op_collab_relay_protocol::{LocatorKeyId, RelayLocatorVerifier, MAX_LOCATOR_KEY_ID_BYTES}; use serde::Deserialize; +use crate::read_bounded_regular_file; + pub const PINNED_VERIFIER_KEY_FILE_VERSION: u32 = 1; pub const MAX_PINNED_VERIFIER_KEY_FILE_BYTES: usize = 64 * 1024; pub const MAX_PINNED_VERIFIER_KEYS: usize = 64; diff --git a/crates/op-collab-relay-locator-server/Cargo.toml b/crates/op-collab-relay-locator-server/Cargo.toml index c2de9e04a..5d559333c 100644 --- a/crates/op-collab-relay-locator-server/Cargo.toml +++ b/crates/op-collab-relay-locator-server/Cargo.toml @@ -21,6 +21,7 @@ op-auth-bridge = { path = "../op-auth-bridge" } op-collab-policy-file = { path = "../op-collab-policy-file" } op-collab-relay-control-plane = { path = "../op-collab-relay-control-plane" } op-collab-relay-protocol = { path = "../op-collab-relay-protocol" } +sha2 = "0.10" socket2 = "0.6" thiserror.workspace = true tokio = { version = "1", features = [ @@ -41,7 +42,9 @@ zeroize = "1" libc = "0.2" [dev-dependencies] +base64 = "0.22" ed25519-dalek = { version = "2.2", default-features = false, features = ["std"] } op-auth-bridge = { path = "../op-auth-bridge", features = ["test-issuer"] } reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] } +serde_json.workspace = true tempfile = "3" diff --git a/crates/op-collab-relay-locator-server/src/lib.rs b/crates/op-collab-relay-locator-server/src/lib.rs index 825f7e1ba..6e12f178b 100644 --- a/crates/op-collab-relay-locator-server/src/lib.rs +++ b/crates/op-collab-relay-locator-server/src/lib.rs @@ -21,8 +21,9 @@ pub use http::{ }; pub use pairing_store::InMemoryPairingStore; pub use production::{ - build_production_pairing, build_production_publisher, ProductionLocatorConfig, - ProductionLocatorConfigError, + build_production_pairing, build_production_publisher, check_production, + ProductionLocatorCheckError, ProductionLocatorConfig, ProductionLocatorConfigError, + LOCATOR_PUBLIC_KEYS_FILE_ENV, }; #[cfg(test)] diff --git a/crates/op-collab-relay-locator-server/src/main.rs b/crates/op-collab-relay-locator-server/src/main.rs index 729bb3fa0..ee82e60b8 100644 --- a/crates/op-collab-relay-locator-server/src/main.rs +++ b/crates/op-collab-relay-locator-server/src/main.rs @@ -1,5 +1,5 @@ use op_collab_relay_locator_server::{ - build_production_pairing, build_production_publisher, serve_listener_until, + build_production_pairing, build_production_publisher, check_production, serve_listener_until, LocatorServerConfig, ProductionLocatorConfig, }; use tracing_subscriber::EnvFilter; @@ -20,9 +20,27 @@ async fn main() { .without_time() .init(); - if !production_requested() { - eprintln!("usage: op-collab-relay-locator-server --production"); - std::process::exit(2); + let command = match parse_arguments(std::env::args().skip(1)) { + Ok(value) => value, + Err(()) => { + eprintln!( + "usage: op-collab-relay-locator-server \ + <--production|--check-production>" + ); + std::process::exit(2); + } + }; + if command == Command::CheckProduction { + match check_production() { + Ok(()) => { + println!("ready"); + return; + } + Err(error) => { + eprintln!("locator production check failed: {error}"); + std::process::exit(1); + } + } } let production = match ProductionLocatorConfig::from_env() { Ok(value) => value, @@ -62,9 +80,28 @@ async fn run( Ok(()) } -fn production_requested() -> bool { - let mut arguments = std::env::args().skip(1); - matches!(arguments.next().as_deref(), Some("--production")) && arguments.next().is_none() +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Command { + Production, + CheckProduction, +} + +fn parse_arguments(arguments: I) -> Result +where + I: IntoIterator, + S: AsRef, +{ + let mut arguments = arguments.into_iter(); + let first = arguments.next().ok_or(())?; + let command = match first.as_ref() { + "--production" => Command::Production, + "--check-production" => Command::CheckProduction, + _ => return Err(()), + }; + if arguments.next().is_some() { + return Err(()); + } + Ok(command) } fn locator_log_filter() -> Result { @@ -112,7 +149,21 @@ async fn shutdown_signal() { mod tests { use std::ffi::OsStr; - use super::locator_log_level; + use super::{locator_log_level, parse_arguments, Command}; + + #[test] + fn production_check_is_a_standalone_mode() { + assert_eq!( + parse_arguments(["--check-production"]), + Ok(Command::CheckProduction) + ); + assert_eq!(parse_arguments(["--production"]), Ok(Command::Production)); + assert_eq!( + parse_arguments(["--check-production", "--production"]), + Err(()) + ); + assert_eq!(parse_arguments(Vec::<&str>::new()), Err(())); + } #[test] fn log_filter_level_is_bounded_and_cannot_enable_dependency_traces() { diff --git a/crates/op-collab-relay-locator-server/src/production.rs b/crates/op-collab-relay-locator-server/src/production.rs index 93ddeb18b..41d9d9020 100644 --- a/crates/op-collab-relay-locator-server/src/production.rs +++ b/crates/op-collab-relay-locator-server/src/production.rs @@ -38,6 +38,7 @@ pub const LOCATOR_TICKET_POLICY_FILE_ENV: &str = "OPENPENCIL_COLLAB_LOCATOR_TICK pub const LOCATOR_POLICY_MAX_AGE_ENV: &str = "OPENPENCIL_COLLAB_LOCATOR_POLICY_MAX_AGE_SECONDS"; pub const LOCATOR_HSM_SOCKET_ENV: &str = "OPENPENCIL_COLLAB_LOCATOR_HSM_SOCKET"; pub const LOCATOR_HSM_KEY_ID_ENV: &str = "OPENPENCIL_COLLAB_LOCATOR_HSM_KEY_ID"; +pub const LOCATOR_PUBLIC_KEYS_FILE_ENV: &str = "OPENPENCIL_COLLAB_LOCATOR_PUBLIC_KEYS_FILE"; pub const LOCATOR_HSM_PEER_UID_ENV: &str = "OPENPENCIL_COLLAB_LOCATOR_HSM_PEER_UID"; pub const LOCATOR_HSM_PEER_GID_ENV: &str = "OPENPENCIL_COLLAB_LOCATOR_HSM_PEER_GID"; pub const LOCATOR_HSM_TIMEOUT_MS_ENV: &str = "OPENPENCIL_COLLAB_LOCATOR_HSM_TIMEOUT_MS"; @@ -52,6 +53,12 @@ const MAX_HSM_TIMEOUT_MS: u64 = 5_000; const MAX_AUTH_IN_FLIGHT: usize = 256; const MAX_RATE_PER_SECOND: u32 = 10_000; +mod production_check; +pub use production_check::{check_production, ProductionLocatorCheckError}; + +#[cfg(test)] +mod production_check_tests; + pub struct ProductionLocatorConfig { server: LocatorServerConfig, home_region: RelayRegion, diff --git a/crates/op-collab-relay-locator-server/src/production/production_check.rs b/crates/op-collab-relay-locator-server/src/production/production_check.rs new file mode 100644 index 000000000..945a5c27f --- /dev/null +++ b/crates/op-collab-relay-locator-server/src/production/production_check.rs @@ -0,0 +1,163 @@ +use std::{ffi::OsString, num::NonZeroU64, path::Path, time::SystemTime}; + +use op_auth_bridge::{CollabUnionPolicy, CollabVerifierConfig, DEFAULT_MAX_COLLAB_JWKS_BYTES}; +use op_collab_policy_file::{read_bounded_regular_file, PinnedEd25519LocatorVerifier}; +use op_collab_relay_control_plane::RelayLocatorSigner; +use op_collab_relay_protocol::{ + ExpectedDiscoveryId, OwnerNoiseStatic, RelayLocatorVerifier, RouteId, UnsignedRelayLocatorV1, +}; +use sha2::{Digest as _, Sha256}; + +use super::{required_absolute_path, ProductionLocatorConfig, LOCATOR_PUBLIC_KEYS_FILE_ENV}; +#[cfg(unix)] +use crate::UnixHsmRelayLocatorSigner; + +const EXPIRED_NOT_BEFORE_UNIX: u64 = 1; +const EXPIRED_AT_UNIX: u64 = 2; +const EXPECTED_POLICY_SHA256_ENV: &str = "OPENPENCIL_COLLAB_EXPECTED_POLICY_SHA256"; + +/// Verify the production policy and exercise one real HSM signing round trip. +pub fn check_production() -> Result<(), ProductionLocatorCheckError> { + let now_unix_seconds = SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .ok() + .map(|duration| duration.as_secs()) + .filter(|now| *now > EXPIRED_AT_UNIX) + .ok_or(ProductionLocatorCheckError::Clock)?; + let config = ProductionLocatorConfig::from_env() + .map_err(|_| ProductionLocatorCheckError::Configuration)?; + let public_keys_file = required_absolute_path(LOCATOR_PUBLIC_KEYS_FILE_ENV) + .map_err(|_| ProductionLocatorCheckError::Configuration)?; + let expected_policy_sha256 = expected_policy_sha256_from_env()?; + check_production_config_at( + &config, + &public_keys_file, + now_unix_seconds, + &expected_policy_sha256, + ) +} + +#[cfg(unix)] +pub(crate) fn check_production_config_at( + config: &ProductionLocatorConfig, + public_keys_file: &Path, + now_unix_seconds: u64, + expected_policy_sha256: &str, +) -> Result<(), ProductionLocatorCheckError> { + if now_unix_seconds <= EXPIRED_AT_UNIX { + return Err(ProductionLocatorCheckError::Clock); + } + let verifier_config = CollabVerifierConfig::production(); + let policy_body = + read_bounded_regular_file(&config.ticket_policy_file, DEFAULT_MAX_COLLAB_JWKS_BYTES) + .map_err(|_| ProductionLocatorCheckError::Policy)?; + if format!("{:x}", Sha256::digest(&policy_body)) != expected_policy_sha256 { + return Err(ProductionLocatorCheckError::Policy); + } + CollabUnionPolicy::from_json( + &policy_body, + DEFAULT_MAX_COLLAB_JWKS_BYTES, + verifier_config.issuer(), + now_unix_seconds, + ) + .map_err(|_| ProductionLocatorCheckError::Policy)?; + + let verifier = PinnedEd25519LocatorVerifier::from_file(public_keys_file) + .map_err(|_| ProductionLocatorCheckError::LocatorKeys)?; + let signer = UnixHsmRelayLocatorSigner::new( + &config.hsm_socket, + config.hsm_key_id.clone(), + config.hsm_peer, + config.hsm_timeout, + ) + .map_err(|_| ProductionLocatorCheckError::Hsm)?; + signer + .validate_socket() + .map_err(|_| ProductionLocatorCheckError::Hsm)?; + + let claims = fixed_expired_claims(config)?; + if claims.validate_pairing_window(now_unix_seconds).is_ok() { + return Err(ProductionLocatorCheckError::ProbeProfile); + } + let canonical = claims.canonical_signing_bytes(); + let signature = signer + .sign(&config.hsm_key_id, &canonical) + .map_err(|_| ProductionLocatorCheckError::Hsm)?; + if !verifier.verify(&config.hsm_key_id, &canonical, signature.as_bytes()) { + return Err(ProductionLocatorCheckError::Signature); + } + Ok(()) +} + +#[cfg(not(unix))] +pub(crate) fn check_production_config_at( + _config: &ProductionLocatorConfig, + _public_keys_file: &Path, + _now_unix_seconds: u64, + _expected_policy_sha256: &str, +) -> Result<(), ProductionLocatorCheckError> { + Err(ProductionLocatorCheckError::UnsupportedPlatform) +} + +fn expected_policy_sha256_from_env() -> Result { + parse_expected_policy_sha256(std::env::var_os(EXPECTED_POLICY_SHA256_ENV)) +} + +pub(crate) fn parse_expected_policy_sha256( + value: Option, +) -> Result { + let value = value + .ok_or(ProductionLocatorCheckError::Configuration)? + .into_string() + .map_err(|_| ProductionLocatorCheckError::Configuration)?; + if value.len() != 64 + || !value + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + { + return Err(ProductionLocatorCheckError::Configuration); + } + Ok(value) +} + +fn fixed_expired_claims( + config: &ProductionLocatorConfig, +) -> Result { + let route_id = + RouteId::new([0x51; 16]).map_err(|_| ProductionLocatorCheckError::ProbeProfile)?; + let owner_static = + OwnerNoiseStatic::new([0x52; 32]).map_err(|_| ProductionLocatorCheckError::ProbeProfile)?; + let discovery = ExpectedDiscoveryId::new("production-check-expired-v1") + .map_err(|_| ProductionLocatorCheckError::ProbeProfile)?; + UnsignedRelayLocatorV1::new( + config.home_region, + route_id, + NonZeroU64::new(1).expect("fixed generation is non-zero"), + owner_static, + discovery, + EXPIRED_NOT_BEFORE_UNIX, + EXPIRED_AT_UNIX, + config.hsm_key_id.clone(), + ) + .map_err(|_| ProductionLocatorCheckError::ProbeProfile) +} + +#[derive(Clone, Copy, Debug, thiserror::Error, PartialEq, Eq)] +pub enum ProductionLocatorCheckError { + #[error("configuration")] + Configuration, + #[error("clock")] + Clock, + #[error("signed policy")] + Policy, + #[error("locator verification keys")] + LocatorKeys, + #[error("probe profile")] + ProbeProfile, + #[error("HSM signing")] + Hsm, + #[error("HSM signature verification")] + Signature, + #[error("unsupported platform")] + UnsupportedPlatform, +} diff --git a/crates/op-collab-relay-locator-server/src/production/production_check_tests.rs b/crates/op-collab-relay-locator-server/src/production/production_check_tests.rs new file mode 100644 index 000000000..f603118a7 --- /dev/null +++ b/crates/op-collab-relay-locator-server/src/production/production_check_tests.rs @@ -0,0 +1,222 @@ +#![cfg(unix)] + +use std::{ + ffi::OsString, + io::{Read as _, Write as _}, + net::SocketAddr, + os::unix::net::UnixListener, + path::Path, + thread, + time::Duration, +}; + +use ed25519_dalek::{Signer as _, SigningKey}; +use op_collab_relay_protocol::{LocatorKeyId, RelayRegion}; +use serde_json::json; +use sha2::{Digest as _, Sha256}; + +use super::{ + production_check::{check_production_config_at, parse_expected_policy_sha256}, + ExpectedUnixPeer, LocatorHttpLimits, LocatorServerConfig, ProductionLocatorCheckError, + ProductionLocatorConfig, +}; +use crate::{HSM_SIGN_REQUEST_BYTES, HSM_SIGN_RESPONSE_BYTES}; + +const POLICY: &[u8] = include_bytes!( + "../../../op-auth-bridge/tests/fixtures/zseven-sso-go-union-policy-v2-generation-4.json" +); +const POLICY_NOW: u64 = 1_786_259_066; +const KEY_ID: &str = "locator-check-key"; + +#[test] +fn production_check_uses_policy_mount_and_real_hsm_signature() { + let result = run_check([0x61; 32], [0x61; 32]); + assert_eq!(result, Ok(())); +} + +#[test] +fn production_check_software_verification_rejects_the_wrong_public_key() { + let result = run_check([0x61; 32], [0x62; 32]); + assert_eq!(result, Err(ProductionLocatorCheckError::Signature)); +} + +#[test] +fn production_check_rejects_a_valid_policy_with_the_wrong_expected_digest() { + let result = run_check_with_inputs([0x61; 32], [0x61; 32], POLICY, "0".repeat(64), false); + assert_eq!(result, Err(ProductionLocatorCheckError::Policy)); +} + +#[test] +fn production_check_rejects_a_rewritten_signature_with_a_matching_digest() { + let mut policy: serde_json::Value = serde_json::from_slice(POLICY).expect("policy fixture"); + policy["signature"] = json!( + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + ); + let rewritten_policy = serde_json::to_vec(&policy).expect("policy JSON"); + let expected_policy_sha256 = format!("{:x}", Sha256::digest(&rewritten_policy)); + let result = run_check_with_inputs( + [0x61; 32], + [0x61; 32], + &rewritten_policy, + expected_policy_sha256, + false, + ); + assert_eq!(result, Err(ProductionLocatorCheckError::Policy)); +} + +#[test] +fn production_check_errors_are_safe_categories() { + assert_eq!( + ProductionLocatorCheckError::Configuration.to_string(), + "configuration" + ); + assert_eq!( + ProductionLocatorCheckError::Signature.to_string(), + "HSM signature verification" + ); +} + +#[test] +fn expected_policy_digest_parser_is_strict_and_fail_closed() { + for value in [ + None, + Some(OsString::from("0".repeat(63))), + Some(OsString::from("0".repeat(65))), + Some(OsString::from("A".repeat(64))), + Some(OsString::from("g".repeat(64))), + ] { + assert_eq!( + parse_expected_policy_sha256(value), + Err(ProductionLocatorCheckError::Configuration) + ); + } + assert_eq!( + parse_expected_policy_sha256(Some(OsString::from("a".repeat(64)))), + Ok("a".repeat(64)) + ); +} + +#[test] +fn expected_policy_digest_parser_rejects_non_unicode() { + use std::os::unix::ffi::OsStringExt as _; + + assert_eq!( + parse_expected_policy_sha256(Some(OsString::from_vec(vec![0xff; 64]))), + Err(ProductionLocatorCheckError::Configuration) + ); +} + +fn run_check( + signer_seed: [u8; 32], + published_seed: [u8; 32], +) -> Result<(), ProductionLocatorCheckError> { + run_check_with_inputs(signer_seed, published_seed, POLICY, policy_sha256(), true) +} + +fn run_check_with_inputs( + signer_seed: [u8; 32], + published_seed: [u8; 32], + policy_body: &[u8], + expected_policy_sha256: String, + exercise_hsm: bool, +) -> Result<(), ProductionLocatorCheckError> { + let directory = workspace_tempdir(); + let policy_path = directory.path().join("policy.json"); + let public_keys_path = directory.path().join("locator-public-keys.json"); + let socket_path = directory.path().join("signer.sock"); + std::fs::write(&policy_path, policy_body).expect("policy file"); + write_public_keys(&public_keys_path, published_seed); + + let hsm = exercise_hsm.then(|| { + let listener = UnixListener::bind(&socket_path).expect("HSM socket"); + thread::spawn(move || serve_one_signature(listener, signer_seed)) + }); + let config = ProductionLocatorConfig { + server: LocatorServerConfig::new( + "127.0.0.1:8092".parse::().expect("listen"), + LocatorHttpLimits::default(), + ) + .expect("server config"), + home_region: RelayRegion::Cn, + ticket_policy_file: policy_path, + policy_max_age_seconds: std::num::NonZeroU64::new(60).expect("non-zero"), + hsm_socket: socket_path, + hsm_key_id: LocatorKeyId::new(KEY_ID).expect("key id"), + hsm_peer: current_peer(), + hsm_timeout: Duration::from_secs(1), + }; + let result = check_production_config_at( + &config, + &public_keys_path, + POLICY_NOW, + &expected_policy_sha256, + ); + if let Some(hsm) = hsm { + hsm.join().expect("HSM thread"); + } + result +} + +fn policy_sha256() -> String { + format!("{:x}", Sha256::digest(POLICY)) +} + +fn serve_one_signature(listener: UnixListener, signer_seed: [u8; 32]) { + let (mut stream, _) = listener.accept().expect("HSM accept"); + let mut request = Vec::new(); + stream.read_to_end(&mut request).expect("HSM request"); + assert_eq!(request.len(), HSM_SIGN_REQUEST_BYTES); + assert_eq!(&request[..4], b"OPLS"); + assert_eq!(request[4], 1); + assert_eq!(request[5], 1); + let key_length = usize::from(request[6]); + assert_eq!(&request[7..7 + key_length], KEY_ID.as_bytes()); + let canonical = &request[71..]; + assert_eq!(canonical.len(), 268); + assert_eq!(canonical[0], 1); + assert_eq!(canonical[1], RelayRegion::Cn as u8); + assert_eq!(&canonical[2..18], &[0x51; 16]); + assert_eq!(&canonical[18..26], &1_u64.to_be_bytes()); + assert_eq!(&canonical[26..58], &[0x52; 32]); + assert_eq!(&canonical[187..195], &1_u64.to_be_bytes()); + assert_eq!(&canonical[195..203], &2_u64.to_be_bytes()); + + let signature = SigningKey::from_bytes(&signer_seed) + .sign(canonical) + .to_bytes(); + let mut response = [0_u8; HSM_SIGN_RESPONSE_BYTES]; + response[..4].copy_from_slice(b"OPLR"); + response[4] = 1; + response[5] = 0; + response[6..].copy_from_slice(&signature); + stream.write_all(&response).expect("HSM response"); +} + +fn write_public_keys(path: &Path, seed: [u8; 32]) { + use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; + + let verifying_key = SigningKey::from_bytes(&seed).verifying_key(); + let body = json!({ + "version": 1, + "keys": [{ + "kid": KEY_ID, + "public_key_ed25519": URL_SAFE_NO_PAD.encode(verifying_key.as_bytes()), + }], + }); + std::fs::write(path, serde_json::to_vec(&body).expect("public keys JSON")) + .expect("public keys file"); +} + +fn current_peer() -> ExpectedUnixPeer { + ExpectedUnixPeer { + uid: unsafe { libc::geteuid() }, + gid: unsafe { libc::getegid() }, + } +} + +fn workspace_tempdir() -> tempfile::TempDir { + let system_temp = std::env::temp_dir() + .canonicalize() + .expect("canonical system temp directory"); + tempfile::tempdir_in(system_temp).expect("system temp directory") +} diff --git a/crates/op-collab-relay-server/Cargo.toml b/crates/op-collab-relay-server/Cargo.toml index 9daacbdaf..8d209bce1 100644 --- a/crates/op-collab-relay-server/Cargo.toml +++ b/crates/op-collab-relay-server/Cargo.toml @@ -19,6 +19,7 @@ op-collab-policy-file = { path = "../op-collab-policy-file" } op-collab-relay-protocol = { path = "../op-collab-relay-protocol" } serde.workspace = true serde_json.workspace = true +sha2 = "0.10" subtle = "2.6" thiserror.workspace = true tokio = { version = "1", features = [ @@ -40,3 +41,4 @@ libc = "0.2" [dev-dependencies] op-auth-bridge = { path = "../op-auth-bridge", features = ["test-issuer"] } +tempfile = "3" diff --git a/crates/op-collab-relay-server/src/lib.rs b/crates/op-collab-relay-server/src/lib.rs index 4f2b0ae8e..b71d14e73 100644 --- a/crates/op-collab-relay-server/src/lib.rs +++ b/crates/op-collab-relay-server/src/lib.rs @@ -4,7 +4,6 @@ mod connection; mod connection_reauth; mod error; mod peer_quota; -mod pinned_verifiers; mod production; mod registry; mod server; @@ -16,15 +15,14 @@ pub use auth::{ }; pub use config::{ConfigError, RelayConfig}; pub use error::RelayServerError; -pub use op_collab_policy_file::PinnedPolicyFileFetcher; -pub use pinned_verifiers::{ - PinnedEd25519LocatorVerifier, PinnedVerifierError, MAX_PINNED_VERIFIER_KEYS, - MAX_PINNED_VERIFIER_KEY_FILE_BYTES, PINNED_VERIFIER_KEY_FILE_VERSION, +pub use op_collab_policy_file::{ + PinnedEd25519LocatorVerifier, PinnedPolicyFileFetcher, PinnedVerifierError, + MAX_PINNED_VERIFIER_KEYS, MAX_PINNED_VERIFIER_KEY_FILE_BYTES, PINNED_VERIFIER_KEY_FILE_VERSION, }; pub use production::{ - run_production, ProductionRelayAuthConfig, ProductionRelayAuthConfigError, - ProductionRelayError, HOME_REGION_ENV, LEGACY_TICKET_BEARER_ENV, LOCATOR_KEYS_FILE_ENV, - POLICY_MAX_AGE_ENV, RELAY_X25519_KEYS_FILE_ENV, TICKET_POLICY_FILE_ENV, + check_production, run_production, ProductionRelayAuthConfig, ProductionRelayAuthConfigError, + ProductionRelayCheckError, ProductionRelayError, HOME_REGION_ENV, LEGACY_TICKET_BEARER_ENV, + LOCATOR_KEYS_FILE_ENV, POLICY_MAX_AGE_ENV, RELAY_X25519_KEYS_FILE_ENV, TICKET_POLICY_FILE_ENV, }; pub use server::{run, run_until, run_with_authenticator, run_with_authenticator_until}; pub use x25519_boundary::{ @@ -36,6 +34,8 @@ pub use x25519_boundary::{ #[cfg(test)] mod production_auth_tests; #[cfg(test)] +mod production_check_tests; +#[cfg(test)] mod production_config_tests; #[cfg(test)] mod tests; diff --git a/crates/op-collab-relay-server/src/main.rs b/crates/op-collab-relay-server/src/main.rs index 252effdd8..fa3154d31 100644 --- a/crates/op-collab-relay-server/src/main.rs +++ b/crates/op-collab-relay-server/src/main.rs @@ -1,6 +1,8 @@ use std::ffi::OsStr; -use op_collab_relay_server::{run, run_production, ProductionRelayAuthConfig, RelayConfig}; +use op_collab_relay_server::{ + check_production, run, run_production, ProductionRelayAuthConfig, RelayConfig, +}; use tracing_subscriber::EnvFilter; const LOG_LEVEL_ENV: &str = "OPENPENCIL_COLLAB_RELAY_LOG_LEVEL"; @@ -26,6 +28,18 @@ async fn main() { std::process::exit(2); } }; + if launch_mode == LaunchMode::CheckProduction { + match check_production() { + Ok(()) => { + println!("ready"); + return; + } + Err(error) => { + eprintln!("relay production check failed: {error}"); + std::process::exit(1); + } + } + } let config = match RelayConfig::from_env() { Ok(config) => config, Err(error) => { @@ -65,6 +79,7 @@ async fn main() { .await .map_err(|error| Box::new(error) as Box) } + LaunchMode::CheckProduction => unreachable!("handled before listener configuration"), }; if let Err(error) = result { @@ -96,6 +111,7 @@ enum LaunchMode { FailClosed, UnauthenticatedDev, Production { allow_ticket_binding_only: bool }, + CheckProduction, } fn parse_args() -> Result { @@ -110,6 +126,7 @@ where let mut allow_unauthenticated_dev = false; let mut production = false; let mut allow_ticket_binding_only = false; + let mut check_production = false; for arg in args { match arg.as_ref() { "--allow-unauthenticated-dev" if !allow_unauthenticated_dev => { @@ -121,15 +138,20 @@ where "--allow-ticket-binding-only" if !allow_ticket_binding_only => { allow_ticket_binding_only = true; } + "--check-production" if !check_production => { + check_production = true; + } "--help" | "-h" => { println!( "Usage: op-collab-relay-server [--production \ - [--allow-ticket-binding-only] | --allow-unauthenticated-dev]\n\ + [--allow-ticket-binding-only] | --allow-unauthenticated-dev | \ + --check-production]\n\ \n\ --production loads pinned ticket-policy, locator, region, and relay X25519\n\ verifier configuration from OPENPENCIL_COLLAB_RELAY_* environment variables.\n\ --allow-ticket-binding-only explicitly selects reduced assurance when no\n\ challenge-proof key is configured.\n\ + --check-production verifies the mounted production trust inputs and exits.\n\ The development flag enables capability-only routing without a ticket.\n\ With no mode flag the server fails closed." ); @@ -141,10 +163,15 @@ where if allow_unauthenticated_dev && production { return Err(CliError::ConflictingModes); } + if check_production && (allow_unauthenticated_dev || production || allow_ticket_binding_only) { + return Err(CliError::ConflictingCheckMode); + } if allow_ticket_binding_only && !production { return Err(CliError::BindingOnlyRequiresProduction); } - Ok(if allow_unauthenticated_dev { + Ok(if check_production { + LaunchMode::CheckProduction + } else if allow_unauthenticated_dev { LaunchMode::UnauthenticatedDev } else if production { LaunchMode::Production { @@ -163,6 +190,8 @@ enum CliError { ConflictingModes, #[error("--allow-ticket-binding-only requires --production")] BindingOnlyRequiresProduction, + #[error("--check-production cannot be combined with a server launch mode")] + ConflictingCheckMode, #[error("OPENPENCIL_COLLAB_RELAY_LOG_LEVEL must be one of error, warn, info, or debug")] InvalidLogLevel, } @@ -207,6 +236,22 @@ mod tests { ); } + #[test] + fn production_check_is_a_standalone_mode() { + assert_eq!( + parse_arg_values(["--check-production"]), + Ok(LaunchMode::CheckProduction) + ); + assert_eq!( + parse_arg_values(["--check-production", "--production"]), + Err(CliError::ConflictingCheckMode) + ); + assert_eq!( + parse_arg_values(["--check-production", "--check-production"]), + Err(CliError::UnknownOrDuplicateArgument) + ); + } + #[test] fn relay_log_filter_is_crate_scoped_and_bounded() { assert_eq!( diff --git a/crates/op-collab-relay-server/src/production.rs b/crates/op-collab-relay-server/src/production.rs index e9f1eddd5..b4a4d0d63 100644 --- a/crates/op-collab-relay-server/src/production.rs +++ b/crates/op-collab-relay-server/src/production.rs @@ -5,18 +5,21 @@ use std::{ num::NonZeroU64, path::{Path, PathBuf}, sync::Arc, + time::{SystemTime, UNIX_EPOCH}, }; use op_auth_bridge::{ - CollabJwksCacheLimits, CollabJwksFetchError, CollabTicketVerifier, CollabVerifierConfig, - CollabVerifierConfigError, DEFAULT_MAX_COLLAB_JWKS_BYTES, + CollabJwksCacheLimits, CollabJwksFetchError, CollabTicketVerifier, CollabUnionPolicy, + CollabVerifierConfig, CollabVerifierConfigError, DEFAULT_MAX_COLLAB_JWKS_BYTES, }; +use op_collab_policy_file::read_bounded_regular_file; use op_collab_relay_protocol::RelayRegion; +use sha2::{Digest as _, Sha256}; use crate::{ run_with_authenticator, CollabTicketRelayAuthenticator, PinnedEd25519LocatorVerifier, PinnedPolicyFileFetcher, PinnedVerifierError, PinnedX25519KeyError, PinnedX25519ProofBoundary, - RelayConfig, RelayServerError, + RelayConfig, RelayServerError, RelayServerX25519ProofBoundary, }; pub const HOME_REGION_ENV: &str = "OPENPENCIL_COLLAB_RELAY_HOME_REGION"; @@ -24,6 +27,7 @@ pub const TICKET_POLICY_FILE_ENV: &str = "OPENPENCIL_COLLAB_RELAY_TICKET_POLICY_ pub const LOCATOR_KEYS_FILE_ENV: &str = "OPENPENCIL_COLLAB_RELAY_LOCATOR_KEYS_FILE"; pub const RELAY_X25519_KEYS_FILE_ENV: &str = "OPENPENCIL_COLLAB_RELAY_X25519_KEYS_FILE"; pub const POLICY_MAX_AGE_ENV: &str = "OPENPENCIL_COLLAB_RELAY_POLICY_MAX_AGE_SECONDS"; +pub const EXPECTED_POLICY_SHA256_ENV: &str = "OPENPENCIL_COLLAB_EXPECTED_POLICY_SHA256"; /// Migration switch for the legacy full-collaboration-ticket relay bearer. /// /// `accept` (the default) dual-accepts the claim-minimized relay token and the @@ -35,6 +39,91 @@ pub const LEGACY_TICKET_BEARER_ENV: &str = "OPENPENCIL_COLLAB_RELAY_LEGACY_TICKE const DEFAULT_POLICY_MAX_AGE_SECONDS: u64 = 60; const MAX_POLICY_MAX_AGE_SECONDS: u64 = 60 * 60; +/// Verify every production trust input without opening a network listener. +/// +/// The signed policy is parsed here, rather than merely opened, so the binary's +/// embedded union roots and generation fence are exercised before promotion. +pub fn check_production() -> Result<(), ProductionRelayCheckError> { + let now_unix_seconds = SystemTime::now() + .duration_since(UNIX_EPOCH) + .ok() + .map(|duration| duration.as_secs()) + .filter(|now| *now != 0) + .ok_or(ProductionRelayCheckError::Clock)?; + let config = ProductionRelayAuthConfig::from_env(false) + .map_err(|_| ProductionRelayCheckError::Configuration)?; + let expected_policy_sha256 = expected_policy_sha256_from_env()?; + check_production_config_at(&config, now_unix_seconds, &expected_policy_sha256) +} + +pub(crate) fn check_production_config_at( + config: &ProductionRelayAuthConfig, + now_unix_seconds: u64, + expected_policy_sha256: &str, +) -> Result<(), ProductionRelayCheckError> { + let verifier_config = CollabVerifierConfig::production(); + let policy_body = + read_bounded_regular_file(&config.ticket_policy_file, DEFAULT_MAX_COLLAB_JWKS_BYTES) + .map_err(|_| ProductionRelayCheckError::Policy)?; + if format!("{:x}", Sha256::digest(&policy_body)) != expected_policy_sha256 { + return Err(ProductionRelayCheckError::Policy); + } + CollabUnionPolicy::from_json( + &policy_body, + DEFAULT_MAX_COLLAB_JWKS_BYTES, + verifier_config.issuer(), + now_unix_seconds, + ) + .map_err(|_| ProductionRelayCheckError::Policy)?; + PinnedEd25519LocatorVerifier::from_file(&config.locator_keys_file) + .map_err(|_| ProductionRelayCheckError::LocatorKeys)?; + let x25519_path = config + .relay_x25519_keys_file + .as_ref() + .ok_or(ProductionRelayCheckError::RelayX25519Keys)?; + let boundary = PinnedX25519ProofBoundary::from_file(x25519_path) + .map_err(|_| ProductionRelayCheckError::RelayX25519Keys)?; + boundary + .active_key_id() + .map_err(|_| ProductionRelayCheckError::RelayX25519Keys)?; + Ok(()) +} + +fn expected_policy_sha256_from_env() -> Result { + parse_expected_policy_sha256(env::var_os(EXPECTED_POLICY_SHA256_ENV)) +} + +pub(crate) fn parse_expected_policy_sha256( + value: Option, +) -> Result { + let value = value + .ok_or(ProductionRelayCheckError::Configuration)? + .into_string() + .map_err(|_| ProductionRelayCheckError::Configuration)?; + if value.len() != 64 + || !value + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + { + return Err(ProductionRelayCheckError::Configuration); + } + Ok(value) +} + +#[derive(Clone, Copy, Debug, thiserror::Error, PartialEq, Eq)] +pub enum ProductionRelayCheckError { + #[error("configuration")] + Configuration, + #[error("clock")] + Clock, + #[error("signed policy")] + Policy, + #[error("locator verification keys")] + LocatorKeys, + #[error("relay proof keys")] + RelayX25519Keys, +} + pub struct ProductionRelayAuthConfig { home_region: RelayRegion, ticket_policy_file: PathBuf, diff --git a/crates/op-collab-relay-server/src/production_check_tests.rs b/crates/op-collab-relay-server/src/production_check_tests.rs new file mode 100644 index 000000000..c2980e06c --- /dev/null +++ b/crates/op-collab-relay-server/src/production_check_tests.rs @@ -0,0 +1,176 @@ +use std::{ffi::OsString, num::NonZeroU64, path::Path}; + +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; +use ed25519_dalek::SigningKey; +use op_collab_relay_protocol::RelayRegion; +use serde_json::json; +use sha2::{Digest as _, Sha256}; +use x25519_dalek::{PublicKey, StaticSecret}; + +use crate::{ + production::{check_production_config_at, parse_expected_policy_sha256}, + ProductionRelayAuthConfig, ProductionRelayCheckError, +}; + +const POLICY: &[u8] = include_bytes!( + "../../op-auth-bridge/tests/fixtures/zseven-sso-go-union-policy-v2-generation-4.json" +); +const POLICY_NOW: u64 = 1_786_259_066; + +#[test] +fn production_check_parses_policy_locator_and_x25519_mounts() { + let fixture = Fixture::new(); + let config = fixture.config(); + assert_eq!( + check_production_config_at(&config, POLICY_NOW, &policy_sha256()), + Ok(()) + ); +} + +#[test] +fn production_check_rejects_a_policy_with_a_rewritten_signature() { + let fixture = Fixture::new(); + let mut policy: serde_json::Value = serde_json::from_slice(POLICY).expect("policy fixture"); + policy["signature"] = json!( + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + ); + let rewritten_policy = serde_json::to_vec(&policy).expect("policy JSON"); + std::fs::write( + fixture.directory.path().join("policy.json"), + &rewritten_policy, + ) + .expect("rewrite policy"); + assert_eq!( + check_production_config_at( + &fixture.config(), + POLICY_NOW, + &format!("{:x}", Sha256::digest(&rewritten_policy)), + ), + Err(ProductionRelayCheckError::Policy) + ); +} + +#[test] +fn production_check_rejects_a_valid_policy_with_the_wrong_expected_digest() { + let fixture = Fixture::new(); + assert_eq!( + check_production_config_at(&fixture.config(), POLICY_NOW, &"0".repeat(64)), + Err(ProductionRelayCheckError::Policy) + ); +} + +#[test] +fn production_check_errors_are_safe_categories() { + assert_eq!( + ProductionRelayCheckError::Configuration.to_string(), + "configuration" + ); + assert_eq!( + ProductionRelayCheckError::Policy.to_string(), + "signed policy" + ); + assert_eq!( + ProductionRelayCheckError::RelayX25519Keys.to_string(), + "relay proof keys" + ); +} + +#[test] +fn expected_policy_digest_parser_is_strict_and_fail_closed() { + for value in [ + None, + Some(OsString::from("0".repeat(63))), + Some(OsString::from("0".repeat(65))), + Some(OsString::from("A".repeat(64))), + Some(OsString::from("g".repeat(64))), + ] { + assert_eq!( + parse_expected_policy_sha256(value), + Err(ProductionRelayCheckError::Configuration) + ); + } + assert_eq!( + parse_expected_policy_sha256(Some(OsString::from("a".repeat(64)))), + Ok("a".repeat(64)) + ); +} + +#[cfg(unix)] +#[test] +fn expected_policy_digest_parser_rejects_non_unicode() { + use std::os::unix::ffi::OsStringExt as _; + + assert_eq!( + parse_expected_policy_sha256(Some(OsString::from_vec(vec![0xff; 64]))), + Err(ProductionRelayCheckError::Configuration) + ); +} + +struct Fixture { + directory: tempfile::TempDir, +} + +impl Fixture { + fn new() -> Self { + let directory = tempfile::tempdir().expect("temporary directory"); + std::fs::write(directory.path().join("policy.json"), POLICY).expect("policy file"); + write_locator_keys(directory.path()); + write_x25519_keys(directory.path()); + Self { directory } + } + + fn config(&self) -> ProductionRelayAuthConfig { + ProductionRelayAuthConfig::new( + RelayRegion::Cn, + self.directory.path().join("policy.json"), + self.directory.path().join("locator-keys.json"), + Some(self.directory.path().join("x25519-keys.json")), + NonZeroU64::new(60).expect("non-zero"), + false, + ) + .expect("production config") + } +} + +fn write_locator_keys(directory: &Path) { + let key = SigningKey::from_bytes(&[0x31; 32]); + let body = json!({ + "version": 1, + "keys": [{ + "kid": "locator-check-key", + "public_key_ed25519": URL_SAFE_NO_PAD.encode(key.verifying_key().as_bytes()), + }], + }); + std::fs::write( + directory.join("locator-keys.json"), + serde_json::to_vec(&body).expect("locator keys JSON"), + ) + .expect("locator keys file"); +} + +fn write_x25519_keys(directory: &Path) { + let secret = StaticSecret::from([0x41; 32]); + let public = PublicKey::from(&secret); + let body = json!({ + "version": 1, + "active_kid": "relay-check-key", + "keys": [{ + "kid": "relay-check-key", + "private_key_x25519": URL_SAFE_NO_PAD.encode(secret.to_bytes()), + "public_key_x25519": URL_SAFE_NO_PAD.encode(public.as_bytes()), + }], + }); + let path = directory.join("x25519-keys.json"); + std::fs::write(&path, serde_json::to_vec(&body).expect("X25519 keys JSON")) + .expect("X25519 keys file"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)) + .expect("private key permissions"); + } +} + +fn policy_sha256() -> String { + format!("{:x}", Sha256::digest(POLICY)) +} diff --git a/docs/security/p2p-collaboration-threat-model.md b/docs/security/p2p-collaboration-threat-model.md index c110cb6ce..e1501f990 100644 --- a/docs/security/p2p-collaboration-threat-model.md +++ b/docs/security/p2p-collaboration-threat-model.md @@ -131,7 +131,13 @@ The desktop reads `OPENPENCIL_SSO_URL`, `OPENPENCIL_COLLAB_ISSUER`, and `OPENPENCIL_COLLAB_POLICY_ENDPOINT` only from trusted process-startup configuration. Production fetches `/api/v1/collab/policy`; the envelope must -verify under the offline Ed25519 root pinned into the open client. Endpoint-only +verify under an offline Ed25519 root pinned into the open client. The emergency +root transition is bounded by policy generation: the legacy union-policy root +is authorized only for generations 1-3, and the replacement union-policy root +only for generation 4 and later. The +client verifies every compile-time root and requires exactly one signature +match and exactly one generation-authorized match. A production environment +variable cannot add or replace a union-policy root. Endpoint-only configuration, conflicting policy/JWKS endpoints, signature or issuer mismatch, inactive key metadata, generation rollback, and same-generation rewrites fail closed without a raw-JWKS fallback. The old @@ -160,16 +166,20 @@ store — the open-source tree contains no production endpoint, and a build without the injection keeps the relay purely environment-configured. A persisted user preference selects which hub serves the signed bootstrap document, and — absent an override — which region an owner publishes in. Both -hubs serve the same signed document verified against the same embedded root, +hubs serve the same signed document verified against the same embedded root set, so the preference is a reachability choice, not a trust choice. `OPENPENCIL_COLLAB_BOOTSTRAP_URL` remains as an operator override that wins when set and stays fail-closed on an invalid value, and an owner may still pin `OPENPENCIL_COLLAB_RELAY_HOME_REGION=cn|global` as a local home selector that overrides the preference. A guest obtains its home region only from the -signed invite or the region-tagged pairing code. 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. +signed invite or the region-tagged pairing code. During the emergency +transition the embedded bootstrap root set contains the legacy +`openpencil-collab-union-root-v2` key and the independent +`openpencil-collab-bootstrap-root-v2` key. The envelope `kid` selects exactly +one of them before signature verification. The legacy root is authorized only +through bootstrap generation 2, while the successor root is authorized from +generation 3 onward. An unknown id, a signature made by the other root, or a +root outside its generation range fails closed. The bootstrap URL must be HTTPS with the exact `/api/v1/collaboration/bootstrap` path and no credentials, query, or fragment. @@ -500,9 +510,10 @@ 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. +region keys inside the signed snapshot. Rotating the embedded root still +requires a coordinated client-and-service release. The bounded dual-root set +supports that rollout, but the 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