fix(collab): rotate production trust roots safely
This commit is contained in:
parent
7a24f227aa
commit
e6013d71da
11
Cargo.lock
generated
11
Cargo.lock
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
(¤t, 3, false),
|
||||
(¤t, 4, true),
|
||||
] {
|
||||
let mut value = policy_fixture();
|
||||
value["generation"] = json!(generation);
|
||||
let parsed = parse_test_policy_with_roots(value, NOW, signing_key, &roots);
|
||||
assert_eq!(parsed.is_ok(), accepted, "generation {generation}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dual_root_verification_rejects_unknown_tampered_and_ambiguous_signatures() {
|
||||
let legacy = SigningKey::from_bytes(&[0x31; 32]);
|
||||
let current = SigningKey::from_bytes(&[0x32; 32]);
|
||||
let unknown = SigningKey::from_bytes(&[0x33; 32]);
|
||||
let roots = [
|
||||
PolicyRoot {
|
||||
key: legacy.verifying_key(),
|
||||
minimum_generation: 1,
|
||||
maximum_generation: 3,
|
||||
},
|
||||
PolicyRoot {
|
||||
key: current.verifying_key(),
|
||||
minimum_generation: 4,
|
||||
maximum_generation: 0,
|
||||
},
|
||||
];
|
||||
|
||||
let mut generation_four = policy_fixture();
|
||||
generation_four["generation"] = json!(4);
|
||||
assert_eq!(
|
||||
parse_test_policy_with_roots(generation_four.clone(), NOW, &unknown, &roots),
|
||||
Err(CollabUnionPolicyError::InvalidSignature)
|
||||
);
|
||||
|
||||
let signed = sign_value(generation_four, ¤t);
|
||||
let mut tampered: Value = serde_json::from_slice(&signed).unwrap();
|
||||
tampered["required_regions"][0]["recovery_epoch"] = json!(99);
|
||||
assert_eq!(
|
||||
CollabUnionPolicy::from_json_with_roots(
|
||||
&serde_json::to_vec(&tampered).unwrap(),
|
||||
64 * 1024,
|
||||
ISSUER,
|
||||
NOW,
|
||||
&roots,
|
||||
),
|
||||
Err(CollabUnionPolicyError::InvalidSignature)
|
||||
);
|
||||
|
||||
let ambiguous = [
|
||||
PolicyRoot {
|
||||
key: legacy.verifying_key(),
|
||||
minimum_generation: 1,
|
||||
maximum_generation: 3,
|
||||
},
|
||||
PolicyRoot {
|
||||
key: legacy.verifying_key(),
|
||||
minimum_generation: 4,
|
||||
maximum_generation: 0,
|
||||
},
|
||||
];
|
||||
let mut generation_three = policy_fixture();
|
||||
generation_three["generation"] = json!(3);
|
||||
assert_eq!(
|
||||
parse_test_policy_with_roots(generation_three, NOW, &legacy, &ambiguous),
|
||||
Err(CollabUnionPolicyError::InvalidSignature)
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
1
crates/op-auth-bridge/tests/fixtures/zseven-sso-go-union-policy-v2-generation-4.json
vendored
Normal file
1
crates/op-auth-bridge/tests/fixtures/zseven-sso-go-union-policy-v2-generation-4.json
vendored
Normal 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"}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
260
crates/op-collab-host/src/runtime/relay_bootstrap_root_tests.rs
Normal file
260
crates/op-collab-host/src/runtime/relay_bootstrap_root_tests.rs
Normal 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),
|
||||
(¤t, CURRENT_BUILTIN_ROOT_KID, 3),
|
||||
] {
|
||||
assert!(verify_bootstrap(
|
||||
&signed_envelope(signing, kid, generation),
|
||||
&roots,
|
||||
NOW,
|
||||
false,
|
||||
true,
|
||||
)
|
||||
.is_ok());
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
verify_bootstrap(
|
||||
&signed_envelope(¤t, LEGACY_BUILTIN_ROOT_KID, 2),
|
||||
&roots,
|
||||
NOW,
|
||||
false,
|
||||
true,
|
||||
)
|
||||
.unwrap_err(),
|
||||
BootstrapError::InvalidSignature
|
||||
);
|
||||
|
||||
for (signing, kid, generation) in [
|
||||
(&legacy, LEGACY_BUILTIN_ROOT_KID, 3),
|
||||
(¤t, CURRENT_BUILTIN_ROOT_KID, 2),
|
||||
] {
|
||||
assert_eq!(
|
||||
verify_bootstrap(
|
||||
&signed_envelope(signing, kid, generation),
|
||||
&roots,
|
||||
NOW,
|
||||
false,
|
||||
true,
|
||||
)
|
||||
.unwrap_err(),
|
||||
BootstrapError::InvalidSignature
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bootstrap_rejects_unknown_root_and_tampered_payload() {
|
||||
let current = SigningKey::from_bytes(&[0x52; 32]);
|
||||
let roots = HashMap::from([(CURRENT_BUILTIN_ROOT_KID.to_owned(), current.verifying_key())]);
|
||||
|
||||
assert_eq!(
|
||||
verify_bootstrap(
|
||||
&signed_envelope(¤t, "unknown-bootstrap-root", 3),
|
||||
&roots,
|
||||
NOW,
|
||||
false,
|
||||
true,
|
||||
)
|
||||
.unwrap_err(),
|
||||
BootstrapError::UnknownRoot
|
||||
);
|
||||
|
||||
let body = signed_envelope(¤t, CURRENT_BUILTIN_ROOT_KID, 3);
|
||||
let mut envelope: BootstrapEnvelope = serde_json::from_slice(&body).unwrap();
|
||||
let mut tampered: BootstrapPayload =
|
||||
serde_json::from_slice(&decode_bounded(&envelope.payload, MAX_PAYLOAD_BYTES).unwrap())
|
||||
.unwrap();
|
||||
tampered.generation += 1;
|
||||
envelope.payload = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&tampered).unwrap());
|
||||
let tampered = serde_json::to_vec(&envelope).unwrap();
|
||||
assert_eq!(
|
||||
verify_bootstrap(&tampered, &roots, NOW, false, true).unwrap_err(),
|
||||
BootstrapError::InvalidSignature
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn release_profile_ignores_environment_root_material() {
|
||||
let injected = SigningKey::from_bytes(&[0x61; 32]);
|
||||
let raw = format!(
|
||||
"injected_root={}",
|
||||
URL_SAFE_NO_PAD.encode(injected.verifying_key().to_bytes())
|
||||
);
|
||||
let mut release_roots = builtin_roots().unwrap();
|
||||
add_development_roots_for_build(&mut release_roots, false, Some(&raw)).unwrap();
|
||||
assert_eq!(release_roots.len(), BUILTIN_ROOTS.len());
|
||||
assert!(!release_roots.contains_key("injected_root"));
|
||||
|
||||
let mut debug_roots = builtin_roots().unwrap();
|
||||
add_development_roots_for_build(&mut debug_roots, true, Some(&raw)).unwrap();
|
||||
assert_eq!(
|
||||
debug_roots.get("injected_root").unwrap(),
|
||||
&injected.verifying_key()
|
||||
);
|
||||
}
|
||||
85
crates/op-collab-host/src/runtime/relay_bootstrap_roots.rs
Normal file
85
crates/op-collab-host/src/runtime/relay_bootstrap_roots.rs
Normal 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(())
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
eyJ2ZXJzaW9uIjoxLCJraWQiOiJvcGVucGVuY2lsLWNvbGxhYi1ib290c3RyYXAtcm9vdC12MiIsInBheWxvYWQiOiJleUoyWlhKemFXOXVJam94TENKblpXNWxjbUYwYVc5dUlqb3pMQ0p1YjNSZlltVm1iM0psWDNWdWFYZ2lPakUzT0RZeU5Ua3lNRE1zSW01dmRGOWhablJsY2w5MWJtbDRJam94TnpnMk9EWTBNREF6TENKeVpXZHBiMjV6SWpwYmV5SnlaV2RwYjI0aU9pSmpiaUlzSW5KbGJHRjVYM1Z5YkNJNkluZHpjem92TDI5d0xucHpaWFpsYmk1amJpOTJNUzkwZFc1dVpXd2lMQ0pzYjJOaGRHOXlYM1Z5YkNJNkltaDBkSEJ6T2k4dmIzQXVlbk5sZG1WdUxtTnVMM1l4TDJ4dlkyRjBiM0lpTENKc2IyTmhkRzl5WDJ0bGVYTWlPbHQ3SW10cFpDSTZJbXh2WTJGMGIzSXRZMjR0WVdOMGFYWmxMVEl3TWpZd056TXhMVEl4TURRd05UVXlPR0ZsWmpZNU9Ua2lMQ0o0SWpvaVJGaFlTRmR5UlZSSlZFbzRObGhCWkZkS2RIVk5lRzVqVjBaUFpWbGlZMnhFWW1JMU1uZFRTM0Z5V1NKOUxIc2lhMmxrSWpvaWJHOWpZWFJ2Y2kxamJpMXVaWGgwTFRJd01qWXdOek14TFRkbU9HVmtZamMxWXpjNU16RXlOak1pTENKNElqb2liRTVOWkRGbGFXTkVUM05HT0RsbldUWlNTREZJV1hwVFgwczNlSFpyZFZGV2FrdFhiUzF1ZVRSSlp5SjlYU3dpY21Wc1lYbGZlREkxTlRFNVgydGxlWE1pT2x0N0ltdHBaQ0k2SW5KbGJHRjVMV051TFdFdE1qZzJNakZpT1dRNFpXRm1JaXdpZUNJNkluRk1PVlp1WTFKWVkxUXdlVE0yUTJwaFoxVmpTbTVEYkdKemVWUk1PRjk1U1dWUFZUQnNaVVpEVVZraWZTeDdJbXRwWkNJNkluSmxiR0Y1TFdOdUxXNHRZak0wTjJRek5EVXhOamd4SWl3aWVDSTZJbkJPTFV4NVNEbEtjRlZKYzJSeFgxVldaVTFGVGtKeVpUWmhRMWd6WldwWmJVd3RTVEpOTlhCb1Iwa2lmVjE5TEhzaWNtVm5hVzl1SWpvaVoyeHZZbUZzSWl3aWNtVnNZWGxmZFhKc0lqb2lkM056T2k4dmIzQXVlbk5sZG1WdUxuUmxZMmd2ZGpFdmRIVnVibVZzSWl3aWJHOWpZWFJ2Y2w5MWNtd2lPaUpvZEhSd2N6b3ZMMjl3TG5welpYWmxiaTUwWldOb0wzWXhMMnh2WTJGMGIzSWlMQ0pzYjJOaGRHOXlYMnRsZVhNaU9sdDdJbXRwWkNJNklteHZZMkYwYjNJdFoyeHZZbUZzTFdGamRHbDJaUzB5TURJMk1EY3pNUzA1TlRJeE5UTTJOVEZrTXpVM05UUTFJaXdpZUNJNkltdGtSMk5EUWxRelVGazBUMFJ1WDFSYVJIWkJNbkJ0YkUwd1RsWlZWVWcyUTAxb1FuSmlRemcwTkZVaWZTeDdJbXRwWkNJNklteHZZMkYwYjNJdFoyeHZZbUZzTFc1bGVIUXRNakF5TmpBM016RXRaakpsWkRRMlpHTmtPR1F4TmpjNU1TSXNJbmdpT2lJdFptaHFkazVVVFUwelNIUlNjMTk1WkZrd05XUnJVWGsyZVZkaVVsbE1iVmhSVFdWVFIweFlORmgzSW4xZExDSnlaV3hoZVY5NE1qVTFNVGxmYTJWNWN5STZXM3NpYTJsa0lqb2ljbVZzWVhrdFoyd3RZUzB6TUdGa1lXUTNaREJtWVdRaUxDSjRJam9pYkVwbVluUTJSbmhaU1RGeGJEWkdOSGhMYkdoYWQxZzVkVWhOWlY5eVZWZGhhekoyWTA4M2NHSXhOQ0o5TEhzaWEybGtJam9pY21Wc1lYa3RaMnd0YmkwMk5XVmhNemswTmpRd01XRWlMQ0o0SWpvaWR6ZGxWbTl3VVdaRlpGOHpXa2hqVmtoaVdsZFRUVkV3WkZsWFEzbzVPVWc0YlZWRVZHbE9TVWR1T0NKOVhYMWRmUSIsInNpZ25hdHVyZSI6IlM1Qy1kYkc1RmR5eWZod2hBNjYycF9DN0cyUXA5ZlRHaEZURzFSbFRfaDJFWjlWQ29tRC1aYmJNS1JnSThtVDB2SWZkVXNqbDVtWTRWVHoxQlVMdEJBIn0=
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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!(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
176
crates/op-collab-relay-server/src/production_check_tests.rs
Normal file
176
crates/op-collab-relay-server/src/production_check_tests.rs
Normal 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))
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue