fix(collab): rotate production trust roots safely

This commit is contained in:
Kayshen-X 2026-08-09 17:17:08 +08:00
parent 7a24f227aa
commit e6013d71da
24 changed files with 1448 additions and 95 deletions

11
Cargo.lock generated
View file

@ -3768,10 +3768,16 @@ dependencies = [
name = "op-collab-policy-file" name = "op-collab-policy-file"
version = "0.8.3" version = "0.8.3"
dependencies = [ dependencies = [
"base64",
"blake3", "blake3",
"ed25519-dalek",
"libc", "libc",
"op-auth-bridge", "op-auth-bridge",
"op-collab-relay-protocol",
"serde",
"serde_json",
"tempfile", "tempfile",
"thiserror 1.0.69",
] ]
[[package]] [[package]]
@ -3825,6 +3831,7 @@ name = "op-collab-relay-locator-server"
version = "0.8.3" version = "0.8.3"
dependencies = [ dependencies = [
"axum", "axum",
"base64",
"bytes", "bytes",
"ed25519-dalek", "ed25519-dalek",
"hyper", "hyper",
@ -3835,6 +3842,8 @@ dependencies = [
"op-collab-relay-control-plane", "op-collab-relay-control-plane",
"op-collab-relay-protocol", "op-collab-relay-protocol",
"reqwest 0.12.28", "reqwest 0.12.28",
"serde_json",
"sha2",
"socket2 0.6.3", "socket2 0.6.3",
"tempfile", "tempfile",
"thiserror 1.0.69", "thiserror 1.0.69",
@ -3874,7 +3883,9 @@ dependencies = [
"op-collab-relay-protocol", "op-collab-relay-protocol",
"serde", "serde",
"serde_json", "serde_json",
"sha2",
"subtle", "subtle",
"tempfile",
"thiserror 1.0.69", "thiserror 1.0.69",
"tokio", "tokio",
"tokio-tungstenite", "tokio-tungstenite",

View file

@ -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_REGIONS: usize = 8;
pub const MAX_COLLAB_UNION_POLICY_KEYS: usize = 24; pub const MAX_COLLAB_UNION_POLICY_KEYS: usize = 24;
pub const MAX_COLLAB_UNION_POLICY_LIFETIME_SECONDS: i64 = 7 * 24 * 60 * 60; 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 POLICY_DOMAIN: &[u8] = b"openpencil/collab-union-policy/v2\0";
const MAX_REGION_ID_BYTES: usize = 32; const MAX_REGION_ID_BYTES: usize = 32;
const MAX_KEY_ID_BYTES: usize = 128; 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. /// A verified public-key union authorized by the pinned offline root.
#[derive(Clone, PartialEq, Eq)] #[derive(Clone, PartialEq, Eq)]
pub struct CollabUnionPolicy { pub struct CollabUnionPolicy {
@ -42,23 +73,45 @@ impl CollabUnionPolicy {
expected_issuer: &str, expected_issuer: &str,
now_unix_seconds: u64, now_unix_seconds: u64,
) -> Result<Self, CollabUnionPolicyError> { ) -> Result<Self, CollabUnionPolicyError> {
let root = decode_fixed::<32>(COLLAB_UNION_POLICY_ROOT_X) let roots = pinned_policy_roots()?;
.ok_or(CollabUnionPolicyError::InvalidSignature)?; Self::from_json_with_roots(
Self::from_json_with_root(
body, body,
maximum_body_bytes, maximum_body_bytes,
expected_issuer, expected_issuer,
now_unix_seconds, now_unix_seconds,
root, &roots,
) )
} }
#[cfg(test)]
fn from_json_with_root( fn from_json_with_root(
body: &[u8], body: &[u8],
maximum_body_bytes: usize, maximum_body_bytes: usize,
expected_issuer: &str, expected_issuer: &str,
now_unix_seconds: u64, now_unix_seconds: u64,
root: [u8; 32], root: [u8; 32],
) -> Result<Self, CollabUnionPolicyError> {
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<Self, CollabUnionPolicyError> { ) -> Result<Self, CollabUnionPolicyError> {
let maximum_body_bytes = maximum_body_bytes.min(HARD_MAX_COLLAB_JWKS_BYTES); let maximum_body_bytes = maximum_body_bytes.min(HARD_MAX_COLLAB_JWKS_BYTES);
if body.is_empty() || body.len() > maximum_body_bytes { if body.is_empty() || body.len() > maximum_body_bytes {
@ -75,10 +128,12 @@ impl CollabUnionPolicy {
let signature = decode_fixed::<64>(&canonical.signature) let signature = decode_fixed::<64>(&canonical.signature)
.ok_or(CollabUnionPolicyError::InvalidSignature)?; .ok_or(CollabUnionPolicyError::InvalidSignature)?;
let root = VerifyingKey::from_bytes(&root) verify_policy_signature(
.map_err(|_| CollabUnionPolicyError::InvalidSignature)?; canonical.unsigned.generation,
root.verify_strict(&message, &Signature::from_bytes(&signature)) &message,
.map_err(|_| CollabUnionPolicyError::InvalidSignature)?; &Signature::from_bytes(&signature),
roots,
)?;
let policy = Self { let policy = Self {
generation: canonical.unsigned.generation, generation: canonical.unsigned.generation,
@ -165,6 +220,54 @@ impl CollabUnionPolicy {
} }
} }
fn pinned_policy_roots() -> Result<Vec<PolicyRoot>, 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)] #[cfg(test)]
fn canonical_message_for_test( fn canonical_message_for_test(
body: &[u8], body: &[u8],

View file

@ -10,6 +10,8 @@ use super::*;
const ISSUER: &str = "https://collab.example.com"; const ISSUER: &str = "https://collab.example.com";
const NOW: u64 = 1_800_000_000; 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_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)] #[derive(Deserialize)]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
@ -97,6 +99,16 @@ fn parse_test_policy(value: Value, now: u64) -> Result<CollabUnionPolicy, Collab
) )
} }
fn parse_test_policy_with_roots(
value: Value,
now: u64,
signing_key: &SigningKey,
roots: &[PolicyRoot],
) -> Result<CollabUnionPolicy, CollabUnionPolicyError> {
let body = sign_value(value, signing_key);
CollabUnionPolicy::from_json_with_roots(&body, 64 * 1024, ISSUER, now, roots)
}
fn parse_test_body( fn parse_test_body(
body: &[u8], body: &[u8],
maximum_body_bytes: usize, maximum_body_bytes: usize,
@ -176,15 +188,141 @@ fn verifies_the_frozen_go_production_root_fixture() {
} }
#[test] #[test]
fn production_v2_policy_root_is_pinned() { fn verifies_the_frozen_go_generation_four_current_root_fixture() {
let root = decode_fixed::<32>(COLLAB_UNION_POLICY_ROOT_X).unwrap(); let fixture = GO_V2_GENERATION_4_FIXTURE
let mut spki = vec![ .strip_suffix(b"\n")
0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00, .unwrap_or(GO_V2_GENERATION_4_FIXTURE);
];
spki.extend_from_slice(&root);
assert_eq!( assert_eq!(
format!("{:x}", Sha256::digest(&spki)), format!("{:x}", Sha256::digest(fixture)),
"53700c011a688b8077850f1330567c265f97cd5e34c9b67aa6695a3fe8afb20c" "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),
(&current, 3, false),
(&current, 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, &current);
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)
); );
} }

View file

@ -83,9 +83,9 @@ pub use collab_ticket::{
}; };
pub use collab_ticket_error::{CollabTicketError, CollabTicketProviderErrorCode}; pub use collab_ticket_error::{CollabTicketError, CollabTicketProviderErrorCode};
pub use collab_union_policy::{ pub use collab_union_policy::{
CollabUnionPolicy, COLLAB_UNION_POLICY_ROOT_X, COLLAB_UNION_POLICY_VERSION, CollabUnionPolicy, COLLAB_UNION_POLICY_CURRENT_ROOT_X, COLLAB_UNION_POLICY_LEGACY_ROOT_X,
MAX_COLLAB_UNION_POLICY_KEYS, MAX_COLLAB_UNION_POLICY_LIFETIME_SECONDS, COLLAB_UNION_POLICY_ROOT_X, COLLAB_UNION_POLICY_VERSION, MAX_COLLAB_UNION_POLICY_KEYS,
MAX_COLLAB_UNION_POLICY_REGIONS, MAX_COLLAB_UNION_POLICY_LIFETIME_SECONDS, MAX_COLLAB_UNION_POLICY_REGIONS,
}; };
pub use collab_verifier::{ pub use collab_verifier::{
CollabTicketVerifier, MAX_COLLAB_JWS_CLAIMS_BYTES, MAX_COLLAB_JWS_HEADER_BYTES, CollabTicketVerifier, MAX_COLLAB_JWS_CLAIMS_BYTES, MAX_COLLAB_JWS_HEADER_BYTES,

View file

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

View file

@ -33,14 +33,16 @@ mod bootstrap_cache;
#[path = "relay_bootstrap_select.rs"] #[path = "relay_bootstrap_select.rs"]
mod bootstrap_select; mod bootstrap_select;
#[path = "relay_bootstrap_roots.rs"]
mod bootstrap_roots;
use bootstrap_cache::{endpoint_cache_file, read_cache, write_cache, BootstrapCache}; 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) use bootstrap_select::bootstrap_provider;
pub(super) const BOOTSTRAP_URL_ENV: &str = "OPENPENCIL_COLLAB_BOOTSTRAP_URL"; pub(super) const BOOTSTRAP_URL_ENV: &str = "OPENPENCIL_COLLAB_BOOTSTRAP_URL";
#[cfg(any(test, debug_assertions))] #[cfg(any(test, debug_assertions))]
const BOOTSTRAP_DEV_HTTP_ENV: &str = "OPENPENCIL_COLLAB_BOOTSTRAP_DEV_HTTP"; 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_PATH: &str = "/api/v1/collaboration/bootstrap";
const BOOTSTRAP_CONTEXT: &[u8] = b"openpencil/op-hub/collaboration-bootstrap/v1\0"; 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_CACHE_FILE: &str = "collaboration-bootstrap-v1.json";
const BOOTSTRAP_CONTENT_TYPE: &str = "application/json"; const BOOTSTRAP_CONTENT_TYPE: &str = "application/json";
const BOOTSTRAP_VERSION: u64 = 1; 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_RESPONSE_BYTES: usize = 64 * 1024;
const MAX_PAYLOAD_BYTES: usize = 32 * 1024; const MAX_PAYLOAD_BYTES: usize = 32 * 1024;
const MAX_CACHE_BYTES: u64 = (MAX_RESPONSE_BYTES as u64 * 2) + 4_096; 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; 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_REGION_KEYS: usize = 8;
const MAX_KEY_ID_BYTES: usize = 64; const MAX_KEY_ID_BYTES: usize = 64;
const MAX_RELAY_KEY_ID_BYTES: usize = 30; const MAX_RELAY_KEY_ID_BYTES: usize = 30;
@ -372,6 +368,9 @@ fn verify_bootstrap(
return Err(BootstrapError::InvalidPayload); return Err(BootstrapError::InvalidPayload);
} }
validate_payload(&payload, now, require_current)?; 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 seen_regions = HashSet::new();
let mut regions = Vec::with_capacity(payload.regions.len()); let mut regions = Vec::with_capacity(payload.regions.len());
for wire in payload.regions { for wire in payload.regions {
@ -589,39 +588,6 @@ fn reject_rollback(
Ok(()) 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> { fn decode_fixed<const N: usize>(encoded: &str) -> Result<[u8; N], BootstrapError> {
if encoded.is_empty() || encoded.contains('=') { if encoded.is_empty() || encoded.contains('=') {
return Err(BootstrapError::InvalidBase64); return Err(BootstrapError::InvalidBase64);
@ -791,3 +757,7 @@ enum BootstrapError {
#[cfg(test)] #[cfg(test)]
#[path = "relay_bootstrap_tests.rs"] #[path = "relay_bootstrap_tests.rs"]
mod tests; mod tests;
#[cfg(test)]
#[path = "relay_bootstrap_root_tests.rs"]
mod root_tests;

View file

@ -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<u8> {
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),
(&current, 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(&current, LEGACY_BUILTIN_ROOT_KID, 2),
&roots,
NOW,
false,
true,
)
.unwrap_err(),
BootstrapError::InvalidSignature
);
for (signing, kid, generation) in [
(&legacy, LEGACY_BUILTIN_ROOT_KID, 3),
(&current, 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(&current, "unknown-bootstrap-root", 3),
&roots,
NOW,
false,
true,
)
.unwrap_err(),
BootstrapError::UnknownRoot
);
let body = signed_envelope(&current, 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()
);
}

View file

@ -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<HashMap<String, VerifyingKey>, 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<String, VerifyingKey>,
) -> 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<String, VerifyingKey>,
) -> Result<(), BootstrapError> {
add_development_roots_for_build(roots, false, None)
}
pub(super) fn add_development_roots_for_build(
roots: &mut HashMap<String, VerifyingKey>,
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(())
}

View file

@ -0,0 +1 @@
eyJ2ZXJzaW9uIjoxLCJraWQiOiJvcGVucGVuY2lsLWNvbGxhYi1ib290c3RyYXAtcm9vdC12MiIsInBheWxvYWQiOiJleUoyWlhKemFXOXVJam94TENKblpXNWxjbUYwYVc5dUlqb3pMQ0p1YjNSZlltVm1iM0psWDNWdWFYZ2lPakUzT0RZeU5Ua3lNRE1zSW01dmRGOWhablJsY2w5MWJtbDRJam94TnpnMk9EWTBNREF6TENKeVpXZHBiMjV6SWpwYmV5SnlaV2RwYjI0aU9pSmpiaUlzSW5KbGJHRjVYM1Z5YkNJNkluZHpjem92TDI5d0xucHpaWFpsYmk1amJpOTJNUzkwZFc1dVpXd2lMQ0pzYjJOaGRHOXlYM1Z5YkNJNkltaDBkSEJ6T2k4dmIzQXVlbk5sZG1WdUxtTnVMM1l4TDJ4dlkyRjBiM0lpTENKc2IyTmhkRzl5WDJ0bGVYTWlPbHQ3SW10cFpDSTZJbXh2WTJGMGIzSXRZMjR0WVdOMGFYWmxMVEl3TWpZd056TXhMVEl4TURRd05UVXlPR0ZsWmpZNU9Ua2lMQ0o0SWpvaVJGaFlTRmR5UlZSSlZFbzRObGhCWkZkS2RIVk5lRzVqVjBaUFpWbGlZMnhFWW1JMU1uZFRTM0Z5V1NKOUxIc2lhMmxrSWpvaWJHOWpZWFJ2Y2kxamJpMXVaWGgwTFRJd01qWXdOek14TFRkbU9HVmtZamMxWXpjNU16RXlOak1pTENKNElqb2liRTVOWkRGbGFXTkVUM05HT0RsbldUWlNTREZJV1hwVFgwczNlSFpyZFZGV2FrdFhiUzF1ZVRSSlp5SjlYU3dpY21Wc1lYbGZlREkxTlRFNVgydGxlWE1pT2x0N0ltdHBaQ0k2SW5KbGJHRjVMV051TFdFdE1qZzJNakZpT1dRNFpXRm1JaXdpZUNJNkluRk1PVlp1WTFKWVkxUXdlVE0yUTJwaFoxVmpTbTVEYkdKemVWUk1PRjk1U1dWUFZUQnNaVVpEVVZraWZTeDdJbXRwWkNJNkluSmxiR0Y1TFdOdUxXNHRZak0wTjJRek5EVXhOamd4SWl3aWVDSTZJbkJPTFV4NVNEbEtjRlZKYzJSeFgxVldaVTFGVGtKeVpUWmhRMWd6WldwWmJVd3RTVEpOTlhCb1Iwa2lmVjE5TEhzaWNtVm5hVzl1SWpvaVoyeHZZbUZzSWl3aWNtVnNZWGxmZFhKc0lqb2lkM056T2k4dmIzQXVlbk5sZG1WdUxuUmxZMmd2ZGpFdmRIVnVibVZzSWl3aWJHOWpZWFJ2Y2w5MWNtd2lPaUpvZEhSd2N6b3ZMMjl3TG5welpYWmxiaTUwWldOb0wzWXhMMnh2WTJGMGIzSWlMQ0pzYjJOaGRHOXlYMnRsZVhNaU9sdDdJbXRwWkNJNklteHZZMkYwYjNJdFoyeHZZbUZzTFdGamRHbDJaUzB5TURJMk1EY3pNUzA1TlRJeE5UTTJOVEZrTXpVM05UUTFJaXdpZUNJNkltdGtSMk5EUWxRelVGazBUMFJ1WDFSYVJIWkJNbkJ0YkUwd1RsWlZWVWcyUTAxb1FuSmlRemcwTkZVaWZTeDdJbXRwWkNJNklteHZZMkYwYjNJdFoyeHZZbUZzTFc1bGVIUXRNakF5TmpBM016RXRaakpsWkRRMlpHTmtPR1F4TmpjNU1TSXNJbmdpT2lJdFptaHFkazVVVFUwelNIUlNjMTk1WkZrd05XUnJVWGsyZVZkaVVsbE1iVmhSVFdWVFIweFlORmgzSW4xZExDSnlaV3hoZVY5NE1qVTFNVGxmYTJWNWN5STZXM3NpYTJsa0lqb2ljbVZzWVhrdFoyd3RZUzB6TUdGa1lXUTNaREJtWVdRaUxDSjRJam9pYkVwbVluUTJSbmhaU1RGeGJEWkdOSGhMYkdoYWQxZzVkVWhOWlY5eVZWZGhhekoyWTA4M2NHSXhOQ0o5TEhzaWEybGtJam9pY21Wc1lYa3RaMnd0YmkwMk5XVmhNemswTmpRd01XRWlMQ0o0SWpvaWR6ZGxWbTl3VVdaRlpGOHpXa2hqVmtoaVdsZFRUVkV3WkZsWFEzbzVPVWc0YlZWRVZHbE9TVWR1T0NKOVhYMWRmUSIsInNpZ25hdHVyZSI6IlM1Qy1kYkc1RmR5eWZod2hBNjYycF9DN0cyUXA5ZlRHaEZURzFSbFRfaDJFWjlWQ29tRC1aYmJNS1JnSThtVDB2SWZkVXNqbDVtWTRWVHoxQlVMdEJBIn0=

View file

@ -8,8 +8,14 @@ repository.workspace = true
description = "Bounded no-follow signed-policy file source for OpenPencil collaboration services" description = "Bounded no-follow signed-policy file source for OpenPencil collaboration services"
[dependencies] [dependencies]
base64 = "0.22"
blake3 = "1.5" blake3 = "1.5"
ed25519-dalek = { version = "2.2", default-features = false, features = ["std"] }
op-auth-bridge = { path = "../op-auth-bridge" } 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] [target.'cfg(unix)'.dependencies]
libc = "0.2" libc = "0.2"

View file

@ -13,6 +13,13 @@ use op_auth_bridge::{
CollabVerifierConfig, 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"; 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. /// Typed rejection reasons for a policy file used as a trust root.

View file

@ -3,10 +3,11 @@ use std::{fmt, path::Path, sync::Arc};
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
use ed25519_dalek::{Signature, VerifyingKey}; use ed25519_dalek::{Signature, VerifyingKey};
use op_auth_bridge::CollabJwksFetchError; 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 op_collab_relay_protocol::{LocatorKeyId, RelayLocatorVerifier, MAX_LOCATOR_KEY_ID_BYTES};
use serde::Deserialize; use serde::Deserialize;
use crate::read_bounded_regular_file;
pub const PINNED_VERIFIER_KEY_FILE_VERSION: u32 = 1; 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_KEY_FILE_BYTES: usize = 64 * 1024;
pub const MAX_PINNED_VERIFIER_KEYS: usize = 64; pub const MAX_PINNED_VERIFIER_KEYS: usize = 64;

View file

@ -21,6 +21,7 @@ op-auth-bridge = { path = "../op-auth-bridge" }
op-collab-policy-file = { path = "../op-collab-policy-file" } op-collab-policy-file = { path = "../op-collab-policy-file" }
op-collab-relay-control-plane = { path = "../op-collab-relay-control-plane" } op-collab-relay-control-plane = { path = "../op-collab-relay-control-plane" }
op-collab-relay-protocol = { path = "../op-collab-relay-protocol" } op-collab-relay-protocol = { path = "../op-collab-relay-protocol" }
sha2 = "0.10"
socket2 = "0.6" socket2 = "0.6"
thiserror.workspace = true thiserror.workspace = true
tokio = { version = "1", features = [ tokio = { version = "1", features = [
@ -41,7 +42,9 @@ zeroize = "1"
libc = "0.2" libc = "0.2"
[dev-dependencies] [dev-dependencies]
base64 = "0.22"
ed25519-dalek = { version = "2.2", default-features = false, features = ["std"] } ed25519-dalek = { version = "2.2", default-features = false, features = ["std"] }
op-auth-bridge = { path = "../op-auth-bridge", features = ["test-issuer"] } op-auth-bridge = { path = "../op-auth-bridge", features = ["test-issuer"] }
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] } reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] }
serde_json.workspace = true
tempfile = "3" tempfile = "3"

View file

@ -21,8 +21,9 @@ pub use http::{
}; };
pub use pairing_store::InMemoryPairingStore; pub use pairing_store::InMemoryPairingStore;
pub use production::{ pub use production::{
build_production_pairing, build_production_publisher, ProductionLocatorConfig, build_production_pairing, build_production_publisher, check_production,
ProductionLocatorConfigError, ProductionLocatorCheckError, ProductionLocatorConfig, ProductionLocatorConfigError,
LOCATOR_PUBLIC_KEYS_FILE_ENV,
}; };
#[cfg(test)] #[cfg(test)]

View file

@ -1,5 +1,5 @@
use op_collab_relay_locator_server::{ 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, LocatorServerConfig, ProductionLocatorConfig,
}; };
use tracing_subscriber::EnvFilter; use tracing_subscriber::EnvFilter;
@ -20,9 +20,27 @@ async fn main() {
.without_time() .without_time()
.init(); .init();
if !production_requested() { let command = match parse_arguments(std::env::args().skip(1)) {
eprintln!("usage: op-collab-relay-locator-server --production"); Ok(value) => value,
std::process::exit(2); 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() { let production = match ProductionLocatorConfig::from_env() {
Ok(value) => value, Ok(value) => value,
@ -62,9 +80,28 @@ async fn run(
Ok(()) Ok(())
} }
fn production_requested() -> bool { #[derive(Clone, Copy, Debug, PartialEq, Eq)]
let mut arguments = std::env::args().skip(1); enum Command {
matches!(arguments.next().as_deref(), Some("--production")) && arguments.next().is_none() Production,
CheckProduction,
}
fn parse_arguments<I, S>(arguments: I) -> Result<Command, ()>
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
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<EnvFilter, ()> { fn locator_log_filter() -> Result<EnvFilter, ()> {
@ -112,7 +149,21 @@ async fn shutdown_signal() {
mod tests { mod tests {
use std::ffi::OsStr; 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] #[test]
fn log_filter_level_is_bounded_and_cannot_enable_dependency_traces() { fn log_filter_level_is_bounded_and_cannot_enable_dependency_traces() {

View file

@ -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_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_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_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_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_PEER_GID_ENV: &str = "OPENPENCIL_COLLAB_LOCATOR_HSM_PEER_GID";
pub const LOCATOR_HSM_TIMEOUT_MS_ENV: &str = "OPENPENCIL_COLLAB_LOCATOR_HSM_TIMEOUT_MS"; 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_AUTH_IN_FLIGHT: usize = 256;
const MAX_RATE_PER_SECOND: u32 = 10_000; 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 { pub struct ProductionLocatorConfig {
server: LocatorServerConfig, server: LocatorServerConfig,
home_region: RelayRegion, home_region: RelayRegion,

View file

@ -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<String, ProductionLocatorCheckError> {
parse_expected_policy_sha256(std::env::var_os(EXPECTED_POLICY_SHA256_ENV))
}
pub(crate) fn parse_expected_policy_sha256(
value: Option<OsString>,
) -> Result<String, ProductionLocatorCheckError> {
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<UnsignedRelayLocatorV1, ProductionLocatorCheckError> {
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,
}

View file

@ -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::<SocketAddr>().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")
}

View file

@ -19,6 +19,7 @@ op-collab-policy-file = { path = "../op-collab-policy-file" }
op-collab-relay-protocol = { path = "../op-collab-relay-protocol" } op-collab-relay-protocol = { path = "../op-collab-relay-protocol" }
serde.workspace = true serde.workspace = true
serde_json.workspace = true serde_json.workspace = true
sha2 = "0.10"
subtle = "2.6" subtle = "2.6"
thiserror.workspace = true thiserror.workspace = true
tokio = { version = "1", features = [ tokio = { version = "1", features = [
@ -40,3 +41,4 @@ libc = "0.2"
[dev-dependencies] [dev-dependencies]
op-auth-bridge = { path = "../op-auth-bridge", features = ["test-issuer"] } op-auth-bridge = { path = "../op-auth-bridge", features = ["test-issuer"] }
tempfile = "3"

View file

@ -4,7 +4,6 @@ mod connection;
mod connection_reauth; mod connection_reauth;
mod error; mod error;
mod peer_quota; mod peer_quota;
mod pinned_verifiers;
mod production; mod production;
mod registry; mod registry;
mod server; mod server;
@ -16,15 +15,14 @@ pub use auth::{
}; };
pub use config::{ConfigError, RelayConfig}; pub use config::{ConfigError, RelayConfig};
pub use error::RelayServerError; pub use error::RelayServerError;
pub use op_collab_policy_file::PinnedPolicyFileFetcher; pub use op_collab_policy_file::{
pub use pinned_verifiers::{ PinnedEd25519LocatorVerifier, PinnedPolicyFileFetcher, PinnedVerifierError,
PinnedEd25519LocatorVerifier, PinnedVerifierError, MAX_PINNED_VERIFIER_KEYS, MAX_PINNED_VERIFIER_KEYS, MAX_PINNED_VERIFIER_KEY_FILE_BYTES, PINNED_VERIFIER_KEY_FILE_VERSION,
MAX_PINNED_VERIFIER_KEY_FILE_BYTES, PINNED_VERIFIER_KEY_FILE_VERSION,
}; };
pub use production::{ pub use production::{
run_production, ProductionRelayAuthConfig, ProductionRelayAuthConfigError, check_production, run_production, ProductionRelayAuthConfig, ProductionRelayAuthConfigError,
ProductionRelayError, HOME_REGION_ENV, LEGACY_TICKET_BEARER_ENV, LOCATOR_KEYS_FILE_ENV, ProductionRelayCheckError, ProductionRelayError, HOME_REGION_ENV, LEGACY_TICKET_BEARER_ENV,
POLICY_MAX_AGE_ENV, RELAY_X25519_KEYS_FILE_ENV, TICKET_POLICY_FILE_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 server::{run, run_until, run_with_authenticator, run_with_authenticator_until};
pub use x25519_boundary::{ pub use x25519_boundary::{
@ -36,6 +34,8 @@ pub use x25519_boundary::{
#[cfg(test)] #[cfg(test)]
mod production_auth_tests; mod production_auth_tests;
#[cfg(test)] #[cfg(test)]
mod production_check_tests;
#[cfg(test)]
mod production_config_tests; mod production_config_tests;
#[cfg(test)] #[cfg(test)]
mod tests; mod tests;

View file

@ -1,6 +1,8 @@
use std::ffi::OsStr; 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; use tracing_subscriber::EnvFilter;
const LOG_LEVEL_ENV: &str = "OPENPENCIL_COLLAB_RELAY_LOG_LEVEL"; const LOG_LEVEL_ENV: &str = "OPENPENCIL_COLLAB_RELAY_LOG_LEVEL";
@ -26,6 +28,18 @@ async fn main() {
std::process::exit(2); 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() { let config = match RelayConfig::from_env() {
Ok(config) => config, Ok(config) => config,
Err(error) => { Err(error) => {
@ -65,6 +79,7 @@ async fn main() {
.await .await
.map_err(|error| Box::new(error) as Box<dyn std::error::Error>) .map_err(|error| Box::new(error) as Box<dyn std::error::Error>)
} }
LaunchMode::CheckProduction => unreachable!("handled before listener configuration"),
}; };
if let Err(error) = result { if let Err(error) = result {
@ -96,6 +111,7 @@ enum LaunchMode {
FailClosed, FailClosed,
UnauthenticatedDev, UnauthenticatedDev,
Production { allow_ticket_binding_only: bool }, Production { allow_ticket_binding_only: bool },
CheckProduction,
} }
fn parse_args() -> Result<LaunchMode, CliError> { fn parse_args() -> Result<LaunchMode, CliError> {
@ -110,6 +126,7 @@ where
let mut allow_unauthenticated_dev = false; let mut allow_unauthenticated_dev = false;
let mut production = false; let mut production = false;
let mut allow_ticket_binding_only = false; let mut allow_ticket_binding_only = false;
let mut check_production = false;
for arg in args { for arg in args {
match arg.as_ref() { match arg.as_ref() {
"--allow-unauthenticated-dev" if !allow_unauthenticated_dev => { "--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" if !allow_ticket_binding_only => {
allow_ticket_binding_only = true; allow_ticket_binding_only = true;
} }
"--check-production" if !check_production => {
check_production = true;
}
"--help" | "-h" => { "--help" | "-h" => {
println!( println!(
"Usage: op-collab-relay-server [--production \ "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\ \n\
--production loads pinned ticket-policy, locator, region, and relay X25519\n\ --production loads pinned ticket-policy, locator, region, and relay X25519\n\
verifier configuration from OPENPENCIL_COLLAB_RELAY_* environment variables.\n\ verifier configuration from OPENPENCIL_COLLAB_RELAY_* environment variables.\n\
--allow-ticket-binding-only explicitly selects reduced assurance when no\n\ --allow-ticket-binding-only explicitly selects reduced assurance when no\n\
challenge-proof key is configured.\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\ The development flag enables capability-only routing without a ticket.\n\
With no mode flag the server fails closed." With no mode flag the server fails closed."
); );
@ -141,10 +163,15 @@ where
if allow_unauthenticated_dev && production { if allow_unauthenticated_dev && production {
return Err(CliError::ConflictingModes); 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 { if allow_ticket_binding_only && !production {
return Err(CliError::BindingOnlyRequiresProduction); return Err(CliError::BindingOnlyRequiresProduction);
} }
Ok(if allow_unauthenticated_dev { Ok(if check_production {
LaunchMode::CheckProduction
} else if allow_unauthenticated_dev {
LaunchMode::UnauthenticatedDev LaunchMode::UnauthenticatedDev
} else if production { } else if production {
LaunchMode::Production { LaunchMode::Production {
@ -163,6 +190,8 @@ enum CliError {
ConflictingModes, ConflictingModes,
#[error("--allow-ticket-binding-only requires --production")] #[error("--allow-ticket-binding-only requires --production")]
BindingOnlyRequiresProduction, 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")] #[error("OPENPENCIL_COLLAB_RELAY_LOG_LEVEL must be one of error, warn, info, or debug")]
InvalidLogLevel, 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] #[test]
fn relay_log_filter_is_crate_scoped_and_bounded() { fn relay_log_filter_is_crate_scoped_and_bounded() {
assert_eq!( assert_eq!(

View file

@ -5,18 +5,21 @@ use std::{
num::NonZeroU64, num::NonZeroU64,
path::{Path, PathBuf}, path::{Path, PathBuf},
sync::Arc, sync::Arc,
time::{SystemTime, UNIX_EPOCH},
}; };
use op_auth_bridge::{ use op_auth_bridge::{
CollabJwksCacheLimits, CollabJwksFetchError, CollabTicketVerifier, CollabVerifierConfig, CollabJwksCacheLimits, CollabJwksFetchError, CollabTicketVerifier, CollabUnionPolicy,
CollabVerifierConfigError, DEFAULT_MAX_COLLAB_JWKS_BYTES, CollabVerifierConfig, CollabVerifierConfigError, DEFAULT_MAX_COLLAB_JWKS_BYTES,
}; };
use op_collab_policy_file::read_bounded_regular_file;
use op_collab_relay_protocol::RelayRegion; use op_collab_relay_protocol::RelayRegion;
use sha2::{Digest as _, Sha256};
use crate::{ use crate::{
run_with_authenticator, CollabTicketRelayAuthenticator, PinnedEd25519LocatorVerifier, run_with_authenticator, CollabTicketRelayAuthenticator, PinnedEd25519LocatorVerifier,
PinnedPolicyFileFetcher, PinnedVerifierError, PinnedX25519KeyError, PinnedX25519ProofBoundary, PinnedPolicyFileFetcher, PinnedVerifierError, PinnedX25519KeyError, PinnedX25519ProofBoundary,
RelayConfig, RelayServerError, RelayConfig, RelayServerError, RelayServerX25519ProofBoundary,
}; };
pub const HOME_REGION_ENV: &str = "OPENPENCIL_COLLAB_RELAY_HOME_REGION"; 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 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 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 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. /// Migration switch for the legacy full-collaboration-ticket relay bearer.
/// ///
/// `accept` (the default) dual-accepts the claim-minimized relay token and the /// `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 DEFAULT_POLICY_MAX_AGE_SECONDS: u64 = 60;
const MAX_POLICY_MAX_AGE_SECONDS: u64 = 60 * 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<String, ProductionRelayCheckError> {
parse_expected_policy_sha256(env::var_os(EXPECTED_POLICY_SHA256_ENV))
}
pub(crate) fn parse_expected_policy_sha256(
value: Option<OsString>,
) -> Result<String, ProductionRelayCheckError> {
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 { pub struct ProductionRelayAuthConfig {
home_region: RelayRegion, home_region: RelayRegion,
ticket_policy_file: PathBuf, ticket_policy_file: PathBuf,

View file

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

View file

@ -131,7 +131,13 @@ The desktop reads `OPENPENCIL_SSO_URL`,
`OPENPENCIL_COLLAB_ISSUER`, and `OPENPENCIL_COLLAB_ISSUER`, and
`OPENPENCIL_COLLAB_POLICY_ENDPOINT` only from trusted process-startup `OPENPENCIL_COLLAB_POLICY_ENDPOINT` only from trusted process-startup
configuration. Production fetches `/api/v1/collab/policy`; the envelope must 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 configuration, conflicting policy/JWKS endpoints, signature or issuer
mismatch, inactive key metadata, generation rollback, and same-generation mismatch, inactive key metadata, generation rollback, and same-generation
rewrites fail closed without a raw-JWKS fallback. The old 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 without the injection keeps the relay purely environment-configured. A
persisted user preference selects which hub serves the signed bootstrap persisted user preference selects which hub serves the signed bootstrap
document, and — absent an override — which region an owner publishes in. Both 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 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 operator override that wins when set and stays fail-closed on an invalid
value, and an owner may still pin value, and an owner may still pin
`OPENPENCIL_COLLAB_RELAY_HOME_REGION=cn|global` as a local home selector that `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 overrides the preference. A guest obtains its home region only from the
signed invite or the region-tagged pairing code. The embedded signed invite or the region-tagged pairing code. During the emergency
`openpencil-collab-root-v1` Ed25519 public key currently has the same bytes as transition the embedded bootstrap root set contains the legacy
the collaboration union-policy root, but the two source constants are not yet `openpencil-collab-union-root-v2` key and the independent
single-sourced; deployment and tests must not assume source-level coupling. `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 The bootstrap URL must be HTTPS with the exact
`/api/v1/collaboration/bootstrap` path and no credentials, query, or fragment. `/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 cross-endpoint global generation ledger without a valid cache. Production
rollouts must publish one byte-identical envelope to domestic and overseas rollouts must publish one byte-identical envelope to domestic and overseas
mirrors, advance the op-hub minimum-generation floor, and preserve overlapping mirrors, advance the op-hub minimum-generation floor, and preserve overlapping
region keys inside the signed snapshot. Rotating the embedded root requires a region keys inside the signed snapshot. Rotating the embedded root still
coordinated client-and-service release; the current production desktop does requires a coordinated client-and-service release. The bounded dual-root set
not load additional roots from runtime configuration. 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, The invite's signed `home_region` is authoritative. Physical geolocation,
bootstrap mirror location, DNS answer, and edge ingress do not authorize a bootstrap mirror location, DNS answer, and edge ingress do not authorize a