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"
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",

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_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<Self, CollabUnionPolicyError> {
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<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> {
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<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)]
fn canonical_message_for_test(
body: &[u8],

View file

@ -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<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(
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),
(&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_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,

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"]
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<HashMap<String, VerifyingKey>, BootstrapError> {
let bytes = decode_fixed::<32>(BUILTIN_ROOT_X)?;
let key = canonical_ed25519_key(bytes, BootstrapError::InvalidRoot)?;
Ok(HashMap::from([(BUILTIN_ROOT_KID.to_owned(), key)]))
}
#[cfg(any(test, debug_assertions))]
fn add_development_roots(roots: &mut HashMap<String, VerifyingKey>) -> Result<(), BootstrapError> {
let Some(raw) = std::env::var(BOOTSTRAP_DEV_ROOT_KEYS_ENV).ok() else {
return Ok(());
};
if raw.is_empty() || raw.len() > MAX_ENV_BYTES {
return Err(BootstrapError::InvalidRoot);
}
for entry in raw.split([',', ';']) {
let (kid, encoded) = entry.split_once('=').ok_or(BootstrapError::InvalidRoot)?;
if !valid_key_id(kid) || roots.len() >= MAX_ROOT_KEYS {
return Err(BootstrapError::InvalidRoot);
}
let bytes = decode_fixed::<32>(encoded)?;
let key = canonical_ed25519_key(bytes, BootstrapError::InvalidRoot)?;
if roots.insert(kid.to_owned(), key).is_some() {
return Err(BootstrapError::InvalidRoot);
}
}
Ok(())
}
#[cfg(not(any(test, debug_assertions)))]
fn add_development_roots(_roots: &mut HashMap<String, VerifyingKey>) -> Result<(), BootstrapError> {
Ok(())
}
fn decode_fixed<const N: usize>(encoded: &str) -> Result<[u8; N], BootstrapError> {
if encoded.is_empty() || encoded.contains('=') {
return Err(BootstrapError::InvalidBase64);
@ -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;

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"
[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"

View file

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

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

View file

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

View file

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

View file

@ -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<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, ()> {
@ -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() {

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_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,

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

View file

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

View file

@ -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<dyn std::error::Error>)
}
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<LaunchMode, CliError> {
@ -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!(

View file

@ -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<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 {
home_region: RelayRegion,
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_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