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"
|
name = "op-collab-policy-file"
|
||||||
version = "0.8.3"
|
version = "0.8.3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"base64",
|
||||||
"blake3",
|
"blake3",
|
||||||
|
"ed25519-dalek",
|
||||||
"libc",
|
"libc",
|
||||||
"op-auth-bridge",
|
"op-auth-bridge",
|
||||||
|
"op-collab-relay-protocol",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
|
"thiserror 1.0.69",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -3825,6 +3831,7 @@ name = "op-collab-relay-locator-server"
|
||||||
version = "0.8.3"
|
version = "0.8.3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"axum",
|
"axum",
|
||||||
|
"base64",
|
||||||
"bytes",
|
"bytes",
|
||||||
"ed25519-dalek",
|
"ed25519-dalek",
|
||||||
"hyper",
|
"hyper",
|
||||||
|
|
@ -3835,6 +3842,8 @@ dependencies = [
|
||||||
"op-collab-relay-control-plane",
|
"op-collab-relay-control-plane",
|
||||||
"op-collab-relay-protocol",
|
"op-collab-relay-protocol",
|
||||||
"reqwest 0.12.28",
|
"reqwest 0.12.28",
|
||||||
|
"serde_json",
|
||||||
|
"sha2",
|
||||||
"socket2 0.6.3",
|
"socket2 0.6.3",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"thiserror 1.0.69",
|
"thiserror 1.0.69",
|
||||||
|
|
@ -3874,7 +3883,9 @@ dependencies = [
|
||||||
"op-collab-relay-protocol",
|
"op-collab-relay-protocol",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"sha2",
|
||||||
"subtle",
|
"subtle",
|
||||||
|
"tempfile",
|
||||||
"thiserror 1.0.69",
|
"thiserror 1.0.69",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-tungstenite",
|
"tokio-tungstenite",
|
||||||
|
|
|
||||||
|
|
@ -16,12 +16,43 @@ pub const COLLAB_UNION_POLICY_VERSION: u32 = 2;
|
||||||
pub const MAX_COLLAB_UNION_POLICY_REGIONS: usize = 8;
|
pub const MAX_COLLAB_UNION_POLICY_REGIONS: usize = 8;
|
||||||
pub const MAX_COLLAB_UNION_POLICY_KEYS: usize = 24;
|
pub const MAX_COLLAB_UNION_POLICY_KEYS: usize = 24;
|
||||||
pub const MAX_COLLAB_UNION_POLICY_LIFETIME_SECONDS: i64 = 7 * 24 * 60 * 60;
|
pub const MAX_COLLAB_UNION_POLICY_LIFETIME_SECONDS: i64 = 7 * 24 * 60 * 60;
|
||||||
pub const COLLAB_UNION_POLICY_ROOT_X: &str = "5SVj-_jnJbuZlpDoD3M9x1eZAPDFLSq5jRb-c0xUh5A";
|
pub const COLLAB_UNION_POLICY_LEGACY_ROOT_X: &str = "5SVj-_jnJbuZlpDoD3M9x1eZAPDFLSq5jRb-c0xUh5A";
|
||||||
|
pub const COLLAB_UNION_POLICY_CURRENT_ROOT_X: &str = "DQJfLM6RZhfcHW52PKmzKNrubWGl0g5p3mBSKNsVOus";
|
||||||
|
/// Compatibility alias for the original generation 1-3 policy root.
|
||||||
|
pub const COLLAB_UNION_POLICY_ROOT_X: &str = COLLAB_UNION_POLICY_LEGACY_ROOT_X;
|
||||||
|
|
||||||
const POLICY_DOMAIN: &[u8] = b"openpencil/collab-union-policy/v2\0";
|
const POLICY_DOMAIN: &[u8] = b"openpencil/collab-union-policy/v2\0";
|
||||||
const MAX_REGION_ID_BYTES: usize = 32;
|
const MAX_REGION_ID_BYTES: usize = 32;
|
||||||
const MAX_KEY_ID_BYTES: usize = 128;
|
const MAX_KEY_ID_BYTES: usize = 128;
|
||||||
|
|
||||||
|
const PINNED_POLICY_ROOTS: [PolicyRootSpec; 2] = [
|
||||||
|
PolicyRootSpec {
|
||||||
|
public_key_x: COLLAB_UNION_POLICY_LEGACY_ROOT_X,
|
||||||
|
minimum_generation: 1,
|
||||||
|
maximum_generation: 3,
|
||||||
|
},
|
||||||
|
PolicyRootSpec {
|
||||||
|
public_key_x: COLLAB_UNION_POLICY_CURRENT_ROOT_X,
|
||||||
|
minimum_generation: 4,
|
||||||
|
maximum_generation: 0,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
struct PolicyRootSpec {
|
||||||
|
public_key_x: &'static str,
|
||||||
|
minimum_generation: u64,
|
||||||
|
/// Zero means no upper bound.
|
||||||
|
maximum_generation: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct PolicyRoot {
|
||||||
|
key: VerifyingKey,
|
||||||
|
minimum_generation: u64,
|
||||||
|
maximum_generation: u64,
|
||||||
|
}
|
||||||
|
|
||||||
/// A verified public-key union authorized by the pinned offline root.
|
/// A verified public-key union authorized by the pinned offline root.
|
||||||
#[derive(Clone, PartialEq, Eq)]
|
#[derive(Clone, PartialEq, Eq)]
|
||||||
pub struct CollabUnionPolicy {
|
pub struct CollabUnionPolicy {
|
||||||
|
|
@ -42,23 +73,45 @@ impl CollabUnionPolicy {
|
||||||
expected_issuer: &str,
|
expected_issuer: &str,
|
||||||
now_unix_seconds: u64,
|
now_unix_seconds: u64,
|
||||||
) -> Result<Self, CollabUnionPolicyError> {
|
) -> Result<Self, CollabUnionPolicyError> {
|
||||||
let root = decode_fixed::<32>(COLLAB_UNION_POLICY_ROOT_X)
|
let roots = pinned_policy_roots()?;
|
||||||
.ok_or(CollabUnionPolicyError::InvalidSignature)?;
|
Self::from_json_with_roots(
|
||||||
Self::from_json_with_root(
|
|
||||||
body,
|
body,
|
||||||
maximum_body_bytes,
|
maximum_body_bytes,
|
||||||
expected_issuer,
|
expected_issuer,
|
||||||
now_unix_seconds,
|
now_unix_seconds,
|
||||||
root,
|
&roots,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
fn from_json_with_root(
|
fn from_json_with_root(
|
||||||
body: &[u8],
|
body: &[u8],
|
||||||
maximum_body_bytes: usize,
|
maximum_body_bytes: usize,
|
||||||
expected_issuer: &str,
|
expected_issuer: &str,
|
||||||
now_unix_seconds: u64,
|
now_unix_seconds: u64,
|
||||||
root: [u8; 32],
|
root: [u8; 32],
|
||||||
|
) -> Result<Self, CollabUnionPolicyError> {
|
||||||
|
let root = VerifyingKey::from_bytes(&root)
|
||||||
|
.map_err(|_| CollabUnionPolicyError::InvalidSignature)?;
|
||||||
|
Self::from_json_with_roots(
|
||||||
|
body,
|
||||||
|
maximum_body_bytes,
|
||||||
|
expected_issuer,
|
||||||
|
now_unix_seconds,
|
||||||
|
&[PolicyRoot {
|
||||||
|
key: root,
|
||||||
|
minimum_generation: 1,
|
||||||
|
maximum_generation: 0,
|
||||||
|
}],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn from_json_with_roots(
|
||||||
|
body: &[u8],
|
||||||
|
maximum_body_bytes: usize,
|
||||||
|
expected_issuer: &str,
|
||||||
|
now_unix_seconds: u64,
|
||||||
|
roots: &[PolicyRoot],
|
||||||
) -> Result<Self, CollabUnionPolicyError> {
|
) -> Result<Self, CollabUnionPolicyError> {
|
||||||
let maximum_body_bytes = maximum_body_bytes.min(HARD_MAX_COLLAB_JWKS_BYTES);
|
let maximum_body_bytes = maximum_body_bytes.min(HARD_MAX_COLLAB_JWKS_BYTES);
|
||||||
if body.is_empty() || body.len() > maximum_body_bytes {
|
if body.is_empty() || body.len() > maximum_body_bytes {
|
||||||
|
|
@ -75,10 +128,12 @@ impl CollabUnionPolicy {
|
||||||
|
|
||||||
let signature = decode_fixed::<64>(&canonical.signature)
|
let signature = decode_fixed::<64>(&canonical.signature)
|
||||||
.ok_or(CollabUnionPolicyError::InvalidSignature)?;
|
.ok_or(CollabUnionPolicyError::InvalidSignature)?;
|
||||||
let root = VerifyingKey::from_bytes(&root)
|
verify_policy_signature(
|
||||||
.map_err(|_| CollabUnionPolicyError::InvalidSignature)?;
|
canonical.unsigned.generation,
|
||||||
root.verify_strict(&message, &Signature::from_bytes(&signature))
|
&message,
|
||||||
.map_err(|_| CollabUnionPolicyError::InvalidSignature)?;
|
&Signature::from_bytes(&signature),
|
||||||
|
roots,
|
||||||
|
)?;
|
||||||
|
|
||||||
let policy = Self {
|
let policy = Self {
|
||||||
generation: canonical.unsigned.generation,
|
generation: canonical.unsigned.generation,
|
||||||
|
|
@ -165,6 +220,54 @@ impl CollabUnionPolicy {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn pinned_policy_roots() -> Result<Vec<PolicyRoot>, CollabUnionPolicyError> {
|
||||||
|
PINNED_POLICY_ROOTS
|
||||||
|
.iter()
|
||||||
|
.map(|spec| {
|
||||||
|
let bytes = decode_fixed::<32>(spec.public_key_x)
|
||||||
|
.ok_or(CollabUnionPolicyError::InvalidSignature)?;
|
||||||
|
let key = VerifyingKey::from_bytes(&bytes)
|
||||||
|
.map_err(|_| CollabUnionPolicyError::InvalidSignature)?;
|
||||||
|
if spec.minimum_generation == 0
|
||||||
|
|| (spec.maximum_generation != 0
|
||||||
|
&& spec.maximum_generation < spec.minimum_generation)
|
||||||
|
{
|
||||||
|
return Err(CollabUnionPolicyError::InvalidSignature);
|
||||||
|
}
|
||||||
|
Ok(PolicyRoot {
|
||||||
|
key,
|
||||||
|
minimum_generation: spec.minimum_generation,
|
||||||
|
maximum_generation: spec.maximum_generation,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn verify_policy_signature(
|
||||||
|
generation: u64,
|
||||||
|
message: &[u8],
|
||||||
|
signature: &Signature,
|
||||||
|
roots: &[PolicyRoot],
|
||||||
|
) -> Result<(), CollabUnionPolicyError> {
|
||||||
|
let mut matching_roots = 0_u8;
|
||||||
|
let mut authorized_roots = 0_u8;
|
||||||
|
for root in roots {
|
||||||
|
if root.key.verify_strict(message, signature).is_err() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
matching_roots = matching_roots.saturating_add(1);
|
||||||
|
if generation >= root.minimum_generation
|
||||||
|
&& (root.maximum_generation == 0 || generation <= root.maximum_generation)
|
||||||
|
{
|
||||||
|
authorized_roots = authorized_roots.saturating_add(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if matching_roots != 1 || authorized_roots != 1 {
|
||||||
|
return Err(CollabUnionPolicyError::InvalidSignature);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
fn canonical_message_for_test(
|
fn canonical_message_for_test(
|
||||||
body: &[u8],
|
body: &[u8],
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,8 @@ use super::*;
|
||||||
const ISSUER: &str = "https://collab.example.com";
|
const ISSUER: &str = "https://collab.example.com";
|
||||||
const NOW: u64 = 1_800_000_000;
|
const NOW: u64 = 1_800_000_000;
|
||||||
const GO_V2_FIXTURE: &str = include_str!("../tests/fixtures/zseven-sso-go-union-policy-v2.json");
|
const GO_V2_FIXTURE: &str = include_str!("../tests/fixtures/zseven-sso-go-union-policy-v2.json");
|
||||||
|
const GO_V2_GENERATION_4_FIXTURE: &[u8] =
|
||||||
|
include_bytes!("../tests/fixtures/zseven-sso-go-union-policy-v2-generation-4.json");
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
#[serde(deny_unknown_fields)]
|
#[serde(deny_unknown_fields)]
|
||||||
|
|
@ -97,6 +99,16 @@ fn parse_test_policy(value: Value, now: u64) -> Result<CollabUnionPolicy, Collab
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn parse_test_policy_with_roots(
|
||||||
|
value: Value,
|
||||||
|
now: u64,
|
||||||
|
signing_key: &SigningKey,
|
||||||
|
roots: &[PolicyRoot],
|
||||||
|
) -> Result<CollabUnionPolicy, CollabUnionPolicyError> {
|
||||||
|
let body = sign_value(value, signing_key);
|
||||||
|
CollabUnionPolicy::from_json_with_roots(&body, 64 * 1024, ISSUER, now, roots)
|
||||||
|
}
|
||||||
|
|
||||||
fn parse_test_body(
|
fn parse_test_body(
|
||||||
body: &[u8],
|
body: &[u8],
|
||||||
maximum_body_bytes: usize,
|
maximum_body_bytes: usize,
|
||||||
|
|
@ -176,15 +188,141 @@ fn verifies_the_frozen_go_production_root_fixture() {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn production_v2_policy_root_is_pinned() {
|
fn verifies_the_frozen_go_generation_four_current_root_fixture() {
|
||||||
let root = decode_fixed::<32>(COLLAB_UNION_POLICY_ROOT_X).unwrap();
|
let fixture = GO_V2_GENERATION_4_FIXTURE
|
||||||
let mut spki = vec![
|
.strip_suffix(b"\n")
|
||||||
0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00,
|
.unwrap_or(GO_V2_GENERATION_4_FIXTURE);
|
||||||
];
|
|
||||||
spki.extend_from_slice(&root);
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
format!("{:x}", Sha256::digest(&spki)),
|
format!("{:x}", Sha256::digest(fixture)),
|
||||||
"53700c011a688b8077850f1330567c265f97cd5e34c9b67aa6695a3fe8afb20c"
|
"b02f32f7827b7d7056c97997c0f953f44ad3b18928676e9a46a8192dd059ee93"
|
||||||
|
);
|
||||||
|
let wire: PolicyWire = serde_json::from_slice(fixture).unwrap();
|
||||||
|
assert_eq!(wire.generation, 4);
|
||||||
|
let issuer = wire.issuer.clone();
|
||||||
|
let now = u64::try_from(wire.not_before_unix).unwrap() + 1;
|
||||||
|
let policy = CollabUnionPolicy::from_json(fixture, 64 * 1024, &issuer, now).unwrap();
|
||||||
|
assert_eq!(policy.generation(), 4);
|
||||||
|
assert_eq!(policy.issuer(), "https://sso.zseven.cn");
|
||||||
|
assert_eq!(policy.recovery_epoch("cn"), Some(1));
|
||||||
|
assert_eq!(policy.recovery_epoch("global"), Some(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn production_v2_policy_root_is_pinned() {
|
||||||
|
for (encoded, expected) in [
|
||||||
|
(
|
||||||
|
COLLAB_UNION_POLICY_LEGACY_ROOT_X,
|
||||||
|
"53700c011a688b8077850f1330567c265f97cd5e34c9b67aa6695a3fe8afb20c",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
COLLAB_UNION_POLICY_CURRENT_ROOT_X,
|
||||||
|
"ee695282bf7120eef385743c59cd9d8c900a182f7c518f0df0ca21891cf1809e",
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
let root = decode_fixed::<32>(encoded).unwrap();
|
||||||
|
let mut spki = vec![
|
||||||
|
0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00,
|
||||||
|
];
|
||||||
|
spki.extend_from_slice(&root);
|
||||||
|
assert_eq!(format!("{:x}", Sha256::digest(&spki)), expected);
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
COLLAB_UNION_POLICY_ROOT_X,
|
||||||
|
COLLAB_UNION_POLICY_LEGACY_ROOT_X
|
||||||
|
);
|
||||||
|
assert_eq!(PINNED_POLICY_ROOTS.len(), 2);
|
||||||
|
assert_eq!(PINNED_POLICY_ROOTS[0].minimum_generation, 1);
|
||||||
|
assert_eq!(PINNED_POLICY_ROOTS[0].maximum_generation, 3);
|
||||||
|
assert_eq!(PINNED_POLICY_ROOTS[1].minimum_generation, 4);
|
||||||
|
assert_eq!(PINNED_POLICY_ROOTS[1].maximum_generation, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dual_root_generation_fence_accepts_only_the_authorized_signer() {
|
||||||
|
let legacy = SigningKey::from_bytes(&[0x31; 32]);
|
||||||
|
let current = SigningKey::from_bytes(&[0x32; 32]);
|
||||||
|
let roots = [
|
||||||
|
PolicyRoot {
|
||||||
|
key: legacy.verifying_key(),
|
||||||
|
minimum_generation: 1,
|
||||||
|
maximum_generation: 3,
|
||||||
|
},
|
||||||
|
PolicyRoot {
|
||||||
|
key: current.verifying_key(),
|
||||||
|
minimum_generation: 4,
|
||||||
|
maximum_generation: 0,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
for (signing_key, generation, accepted) in [
|
||||||
|
(&legacy, 3, true),
|
||||||
|
(&legacy, 4, false),
|
||||||
|
(¤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_ticket_error::{CollabTicketError, CollabTicketProviderErrorCode};
|
||||||
pub use collab_union_policy::{
|
pub use collab_union_policy::{
|
||||||
CollabUnionPolicy, COLLAB_UNION_POLICY_ROOT_X, COLLAB_UNION_POLICY_VERSION,
|
CollabUnionPolicy, COLLAB_UNION_POLICY_CURRENT_ROOT_X, COLLAB_UNION_POLICY_LEGACY_ROOT_X,
|
||||||
MAX_COLLAB_UNION_POLICY_KEYS, MAX_COLLAB_UNION_POLICY_LIFETIME_SECONDS,
|
COLLAB_UNION_POLICY_ROOT_X, COLLAB_UNION_POLICY_VERSION, MAX_COLLAB_UNION_POLICY_KEYS,
|
||||||
MAX_COLLAB_UNION_POLICY_REGIONS,
|
MAX_COLLAB_UNION_POLICY_LIFETIME_SECONDS, MAX_COLLAB_UNION_POLICY_REGIONS,
|
||||||
};
|
};
|
||||||
pub use collab_verifier::{
|
pub use collab_verifier::{
|
||||||
CollabTicketVerifier, MAX_COLLAB_JWS_CLAIMS_BYTES, MAX_COLLAB_JWS_HEADER_BYTES,
|
CollabTicketVerifier, MAX_COLLAB_JWS_CLAIMS_BYTES, MAX_COLLAB_JWS_HEADER_BYTES,
|
||||||
|
|
|
||||||
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"]
|
#[path = "relay_bootstrap_select.rs"]
|
||||||
mod bootstrap_select;
|
mod bootstrap_select;
|
||||||
|
|
||||||
|
#[path = "relay_bootstrap_roots.rs"]
|
||||||
|
mod bootstrap_roots;
|
||||||
|
|
||||||
use bootstrap_cache::{endpoint_cache_file, read_cache, write_cache, BootstrapCache};
|
use bootstrap_cache::{endpoint_cache_file, read_cache, write_cache, BootstrapCache};
|
||||||
|
use bootstrap_roots::{add_development_roots, builtin_roots, root_authorizes_generation};
|
||||||
pub(super) use bootstrap_select::bootstrap_provider;
|
pub(super) use bootstrap_select::bootstrap_provider;
|
||||||
|
|
||||||
pub(super) const BOOTSTRAP_URL_ENV: &str = "OPENPENCIL_COLLAB_BOOTSTRAP_URL";
|
pub(super) const BOOTSTRAP_URL_ENV: &str = "OPENPENCIL_COLLAB_BOOTSTRAP_URL";
|
||||||
#[cfg(any(test, debug_assertions))]
|
#[cfg(any(test, debug_assertions))]
|
||||||
const BOOTSTRAP_DEV_HTTP_ENV: &str = "OPENPENCIL_COLLAB_BOOTSTRAP_DEV_HTTP";
|
const BOOTSTRAP_DEV_HTTP_ENV: &str = "OPENPENCIL_COLLAB_BOOTSTRAP_DEV_HTTP";
|
||||||
#[cfg(any(test, debug_assertions))]
|
|
||||||
const BOOTSTRAP_DEV_ROOT_KEYS_ENV: &str = "OPENPENCIL_COLLAB_BOOTSTRAP_DEV_ROOT_KEYS";
|
|
||||||
|
|
||||||
const BOOTSTRAP_PATH: &str = "/api/v1/collaboration/bootstrap";
|
const BOOTSTRAP_PATH: &str = "/api/v1/collaboration/bootstrap";
|
||||||
const BOOTSTRAP_CONTEXT: &[u8] = b"openpencil/op-hub/collaboration-bootstrap/v1\0";
|
const BOOTSTRAP_CONTEXT: &[u8] = b"openpencil/op-hub/collaboration-bootstrap/v1\0";
|
||||||
|
|
@ -49,16 +51,10 @@ const BOOTSTRAP_CONTEXT: &[u8] = b"openpencil/op-hub/collaboration-bootstrap/v1\
|
||||||
const BOOTSTRAP_CACHE_FILE: &str = "collaboration-bootstrap-v1.json";
|
const BOOTSTRAP_CACHE_FILE: &str = "collaboration-bootstrap-v1.json";
|
||||||
const BOOTSTRAP_CONTENT_TYPE: &str = "application/json";
|
const BOOTSTRAP_CONTENT_TYPE: &str = "application/json";
|
||||||
const BOOTSTRAP_VERSION: u64 = 1;
|
const BOOTSTRAP_VERSION: u64 = 1;
|
||||||
const BUILTIN_ROOT_KID: &str = "openpencil-collab-union-root-v2";
|
|
||||||
const BUILTIN_ROOT_X: &str = "5SVj-_jnJbuZlpDoD3M9x1eZAPDFLSq5jRb-c0xUh5A";
|
|
||||||
const MAX_RESPONSE_BYTES: usize = 64 * 1024;
|
const MAX_RESPONSE_BYTES: usize = 64 * 1024;
|
||||||
const MAX_PAYLOAD_BYTES: usize = 32 * 1024;
|
const MAX_PAYLOAD_BYTES: usize = 32 * 1024;
|
||||||
const MAX_CACHE_BYTES: u64 = (MAX_RESPONSE_BYTES as u64 * 2) + 4_096;
|
const MAX_CACHE_BYTES: u64 = (MAX_RESPONSE_BYTES as u64 * 2) + 4_096;
|
||||||
#[cfg(any(test, debug_assertions))]
|
|
||||||
const MAX_ENV_BYTES: usize = 8 * 1024;
|
|
||||||
const MAX_ETAG_BYTES: usize = 256;
|
const MAX_ETAG_BYTES: usize = 256;
|
||||||
#[cfg(any(test, debug_assertions))]
|
|
||||||
const MAX_ROOT_KEYS: usize = 8;
|
|
||||||
const MAX_REGION_KEYS: usize = 8;
|
const MAX_REGION_KEYS: usize = 8;
|
||||||
const MAX_KEY_ID_BYTES: usize = 64;
|
const MAX_KEY_ID_BYTES: usize = 64;
|
||||||
const MAX_RELAY_KEY_ID_BYTES: usize = 30;
|
const MAX_RELAY_KEY_ID_BYTES: usize = 30;
|
||||||
|
|
@ -372,6 +368,9 @@ fn verify_bootstrap(
|
||||||
return Err(BootstrapError::InvalidPayload);
|
return Err(BootstrapError::InvalidPayload);
|
||||||
}
|
}
|
||||||
validate_payload(&payload, now, require_current)?;
|
validate_payload(&payload, now, require_current)?;
|
||||||
|
if !root_authorizes_generation(&envelope.kid, payload.generation) {
|
||||||
|
return Err(BootstrapError::InvalidSignature);
|
||||||
|
}
|
||||||
let mut seen_regions = HashSet::new();
|
let mut seen_regions = HashSet::new();
|
||||||
let mut regions = Vec::with_capacity(payload.regions.len());
|
let mut regions = Vec::with_capacity(payload.regions.len());
|
||||||
for wire in payload.regions {
|
for wire in payload.regions {
|
||||||
|
|
@ -589,39 +588,6 @@ fn reject_rollback(
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn builtin_roots() -> Result<HashMap<String, VerifyingKey>, BootstrapError> {
|
|
||||||
let bytes = decode_fixed::<32>(BUILTIN_ROOT_X)?;
|
|
||||||
let key = canonical_ed25519_key(bytes, BootstrapError::InvalidRoot)?;
|
|
||||||
Ok(HashMap::from([(BUILTIN_ROOT_KID.to_owned(), key)]))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(any(test, debug_assertions))]
|
|
||||||
fn add_development_roots(roots: &mut HashMap<String, VerifyingKey>) -> Result<(), BootstrapError> {
|
|
||||||
let Some(raw) = std::env::var(BOOTSTRAP_DEV_ROOT_KEYS_ENV).ok() else {
|
|
||||||
return Ok(());
|
|
||||||
};
|
|
||||||
if raw.is_empty() || raw.len() > MAX_ENV_BYTES {
|
|
||||||
return Err(BootstrapError::InvalidRoot);
|
|
||||||
}
|
|
||||||
for entry in raw.split([',', ';']) {
|
|
||||||
let (kid, encoded) = entry.split_once('=').ok_or(BootstrapError::InvalidRoot)?;
|
|
||||||
if !valid_key_id(kid) || roots.len() >= MAX_ROOT_KEYS {
|
|
||||||
return Err(BootstrapError::InvalidRoot);
|
|
||||||
}
|
|
||||||
let bytes = decode_fixed::<32>(encoded)?;
|
|
||||||
let key = canonical_ed25519_key(bytes, BootstrapError::InvalidRoot)?;
|
|
||||||
if roots.insert(kid.to_owned(), key).is_some() {
|
|
||||||
return Err(BootstrapError::InvalidRoot);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(not(any(test, debug_assertions)))]
|
|
||||||
fn add_development_roots(_roots: &mut HashMap<String, VerifyingKey>) -> Result<(), BootstrapError> {
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn decode_fixed<const N: usize>(encoded: &str) -> Result<[u8; N], BootstrapError> {
|
fn decode_fixed<const N: usize>(encoded: &str) -> Result<[u8; N], BootstrapError> {
|
||||||
if encoded.is_empty() || encoded.contains('=') {
|
if encoded.is_empty() || encoded.contains('=') {
|
||||||
return Err(BootstrapError::InvalidBase64);
|
return Err(BootstrapError::InvalidBase64);
|
||||||
|
|
@ -791,3 +757,7 @@ enum BootstrapError {
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
#[path = "relay_bootstrap_tests.rs"]
|
#[path = "relay_bootstrap_tests.rs"]
|
||||||
mod tests;
|
mod tests;
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "relay_bootstrap_root_tests.rs"]
|
||||||
|
mod root_tests;
|
||||||
|
|
|
||||||
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"
|
description = "Bounded no-follow signed-policy file source for OpenPencil collaboration services"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
base64 = "0.22"
|
||||||
blake3 = "1.5"
|
blake3 = "1.5"
|
||||||
|
ed25519-dalek = { version = "2.2", default-features = false, features = ["std"] }
|
||||||
op-auth-bridge = { path = "../op-auth-bridge" }
|
op-auth-bridge = { path = "../op-auth-bridge" }
|
||||||
|
op-collab-relay-protocol = { path = "../op-collab-relay-protocol" }
|
||||||
|
serde.workspace = true
|
||||||
|
serde_json.workspace = true
|
||||||
|
thiserror.workspace = true
|
||||||
|
|
||||||
[target.'cfg(unix)'.dependencies]
|
[target.'cfg(unix)'.dependencies]
|
||||||
libc = "0.2"
|
libc = "0.2"
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,13 @@ use op_auth_bridge::{
|
||||||
CollabVerifierConfig,
|
CollabVerifierConfig,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
mod pinned_locator_keys;
|
||||||
|
|
||||||
|
pub use pinned_locator_keys::{
|
||||||
|
PinnedEd25519LocatorVerifier, PinnedVerifierError, MAX_PINNED_VERIFIER_KEYS,
|
||||||
|
MAX_PINNED_VERIFIER_KEY_FILE_BYTES, PINNED_VERIFIER_KEY_FILE_VERSION,
|
||||||
|
};
|
||||||
|
|
||||||
const FILE_ETAG_CONTEXT: &str = "openpencil/op-collab-policy-file/pinned-policy-file-etag/v1";
|
const FILE_ETAG_CONTEXT: &str = "openpencil/op-collab-policy-file/pinned-policy-file-etag/v1";
|
||||||
|
|
||||||
/// Typed rejection reasons for a policy file used as a trust root.
|
/// Typed rejection reasons for a policy file used as a trust root.
|
||||||
|
|
|
||||||
|
|
@ -3,10 +3,11 @@ use std::{fmt, path::Path, sync::Arc};
|
||||||
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
|
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
|
||||||
use ed25519_dalek::{Signature, VerifyingKey};
|
use ed25519_dalek::{Signature, VerifyingKey};
|
||||||
use op_auth_bridge::CollabJwksFetchError;
|
use op_auth_bridge::CollabJwksFetchError;
|
||||||
use op_collab_policy_file::read_bounded_regular_file;
|
|
||||||
use op_collab_relay_protocol::{LocatorKeyId, RelayLocatorVerifier, MAX_LOCATOR_KEY_ID_BYTES};
|
use op_collab_relay_protocol::{LocatorKeyId, RelayLocatorVerifier, MAX_LOCATOR_KEY_ID_BYTES};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
use crate::read_bounded_regular_file;
|
||||||
|
|
||||||
pub const PINNED_VERIFIER_KEY_FILE_VERSION: u32 = 1;
|
pub const PINNED_VERIFIER_KEY_FILE_VERSION: u32 = 1;
|
||||||
pub const MAX_PINNED_VERIFIER_KEY_FILE_BYTES: usize = 64 * 1024;
|
pub const MAX_PINNED_VERIFIER_KEY_FILE_BYTES: usize = 64 * 1024;
|
||||||
pub const MAX_PINNED_VERIFIER_KEYS: usize = 64;
|
pub const MAX_PINNED_VERIFIER_KEYS: usize = 64;
|
||||||
|
|
@ -21,6 +21,7 @@ op-auth-bridge = { path = "../op-auth-bridge" }
|
||||||
op-collab-policy-file = { path = "../op-collab-policy-file" }
|
op-collab-policy-file = { path = "../op-collab-policy-file" }
|
||||||
op-collab-relay-control-plane = { path = "../op-collab-relay-control-plane" }
|
op-collab-relay-control-plane = { path = "../op-collab-relay-control-plane" }
|
||||||
op-collab-relay-protocol = { path = "../op-collab-relay-protocol" }
|
op-collab-relay-protocol = { path = "../op-collab-relay-protocol" }
|
||||||
|
sha2 = "0.10"
|
||||||
socket2 = "0.6"
|
socket2 = "0.6"
|
||||||
thiserror.workspace = true
|
thiserror.workspace = true
|
||||||
tokio = { version = "1", features = [
|
tokio = { version = "1", features = [
|
||||||
|
|
@ -41,7 +42,9 @@ zeroize = "1"
|
||||||
libc = "0.2"
|
libc = "0.2"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
|
base64 = "0.22"
|
||||||
ed25519-dalek = { version = "2.2", default-features = false, features = ["std"] }
|
ed25519-dalek = { version = "2.2", default-features = false, features = ["std"] }
|
||||||
op-auth-bridge = { path = "../op-auth-bridge", features = ["test-issuer"] }
|
op-auth-bridge = { path = "../op-auth-bridge", features = ["test-issuer"] }
|
||||||
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] }
|
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] }
|
||||||
|
serde_json.workspace = true
|
||||||
tempfile = "3"
|
tempfile = "3"
|
||||||
|
|
|
||||||
|
|
@ -21,8 +21,9 @@ pub use http::{
|
||||||
};
|
};
|
||||||
pub use pairing_store::InMemoryPairingStore;
|
pub use pairing_store::InMemoryPairingStore;
|
||||||
pub use production::{
|
pub use production::{
|
||||||
build_production_pairing, build_production_publisher, ProductionLocatorConfig,
|
build_production_pairing, build_production_publisher, check_production,
|
||||||
ProductionLocatorConfigError,
|
ProductionLocatorCheckError, ProductionLocatorConfig, ProductionLocatorConfigError,
|
||||||
|
LOCATOR_PUBLIC_KEYS_FILE_ENV,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
use op_collab_relay_locator_server::{
|
use op_collab_relay_locator_server::{
|
||||||
build_production_pairing, build_production_publisher, serve_listener_until,
|
build_production_pairing, build_production_publisher, check_production, serve_listener_until,
|
||||||
LocatorServerConfig, ProductionLocatorConfig,
|
LocatorServerConfig, ProductionLocatorConfig,
|
||||||
};
|
};
|
||||||
use tracing_subscriber::EnvFilter;
|
use tracing_subscriber::EnvFilter;
|
||||||
|
|
@ -20,9 +20,27 @@ async fn main() {
|
||||||
.without_time()
|
.without_time()
|
||||||
.init();
|
.init();
|
||||||
|
|
||||||
if !production_requested() {
|
let command = match parse_arguments(std::env::args().skip(1)) {
|
||||||
eprintln!("usage: op-collab-relay-locator-server --production");
|
Ok(value) => value,
|
||||||
std::process::exit(2);
|
Err(()) => {
|
||||||
|
eprintln!(
|
||||||
|
"usage: op-collab-relay-locator-server \
|
||||||
|
<--production|--check-production>"
|
||||||
|
);
|
||||||
|
std::process::exit(2);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if command == Command::CheckProduction {
|
||||||
|
match check_production() {
|
||||||
|
Ok(()) => {
|
||||||
|
println!("ready");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
eprintln!("locator production check failed: {error}");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
let production = match ProductionLocatorConfig::from_env() {
|
let production = match ProductionLocatorConfig::from_env() {
|
||||||
Ok(value) => value,
|
Ok(value) => value,
|
||||||
|
|
@ -62,9 +80,28 @@ async fn run(
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn production_requested() -> bool {
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
let mut arguments = std::env::args().skip(1);
|
enum Command {
|
||||||
matches!(arguments.next().as_deref(), Some("--production")) && arguments.next().is_none()
|
Production,
|
||||||
|
CheckProduction,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_arguments<I, S>(arguments: I) -> Result<Command, ()>
|
||||||
|
where
|
||||||
|
I: IntoIterator<Item = S>,
|
||||||
|
S: AsRef<str>,
|
||||||
|
{
|
||||||
|
let mut arguments = arguments.into_iter();
|
||||||
|
let first = arguments.next().ok_or(())?;
|
||||||
|
let command = match first.as_ref() {
|
||||||
|
"--production" => Command::Production,
|
||||||
|
"--check-production" => Command::CheckProduction,
|
||||||
|
_ => return Err(()),
|
||||||
|
};
|
||||||
|
if arguments.next().is_some() {
|
||||||
|
return Err(());
|
||||||
|
}
|
||||||
|
Ok(command)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn locator_log_filter() -> Result<EnvFilter, ()> {
|
fn locator_log_filter() -> Result<EnvFilter, ()> {
|
||||||
|
|
@ -112,7 +149,21 @@ async fn shutdown_signal() {
|
||||||
mod tests {
|
mod tests {
|
||||||
use std::ffi::OsStr;
|
use std::ffi::OsStr;
|
||||||
|
|
||||||
use super::locator_log_level;
|
use super::{locator_log_level, parse_arguments, Command};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn production_check_is_a_standalone_mode() {
|
||||||
|
assert_eq!(
|
||||||
|
parse_arguments(["--check-production"]),
|
||||||
|
Ok(Command::CheckProduction)
|
||||||
|
);
|
||||||
|
assert_eq!(parse_arguments(["--production"]), Ok(Command::Production));
|
||||||
|
assert_eq!(
|
||||||
|
parse_arguments(["--check-production", "--production"]),
|
||||||
|
Err(())
|
||||||
|
);
|
||||||
|
assert_eq!(parse_arguments(Vec::<&str>::new()), Err(()));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn log_filter_level_is_bounded_and_cannot_enable_dependency_traces() {
|
fn log_filter_level_is_bounded_and_cannot_enable_dependency_traces() {
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,7 @@ pub const LOCATOR_TICKET_POLICY_FILE_ENV: &str = "OPENPENCIL_COLLAB_LOCATOR_TICK
|
||||||
pub const LOCATOR_POLICY_MAX_AGE_ENV: &str = "OPENPENCIL_COLLAB_LOCATOR_POLICY_MAX_AGE_SECONDS";
|
pub const LOCATOR_POLICY_MAX_AGE_ENV: &str = "OPENPENCIL_COLLAB_LOCATOR_POLICY_MAX_AGE_SECONDS";
|
||||||
pub const LOCATOR_HSM_SOCKET_ENV: &str = "OPENPENCIL_COLLAB_LOCATOR_HSM_SOCKET";
|
pub const LOCATOR_HSM_SOCKET_ENV: &str = "OPENPENCIL_COLLAB_LOCATOR_HSM_SOCKET";
|
||||||
pub const LOCATOR_HSM_KEY_ID_ENV: &str = "OPENPENCIL_COLLAB_LOCATOR_HSM_KEY_ID";
|
pub const LOCATOR_HSM_KEY_ID_ENV: &str = "OPENPENCIL_COLLAB_LOCATOR_HSM_KEY_ID";
|
||||||
|
pub const LOCATOR_PUBLIC_KEYS_FILE_ENV: &str = "OPENPENCIL_COLLAB_LOCATOR_PUBLIC_KEYS_FILE";
|
||||||
pub const LOCATOR_HSM_PEER_UID_ENV: &str = "OPENPENCIL_COLLAB_LOCATOR_HSM_PEER_UID";
|
pub const LOCATOR_HSM_PEER_UID_ENV: &str = "OPENPENCIL_COLLAB_LOCATOR_HSM_PEER_UID";
|
||||||
pub const LOCATOR_HSM_PEER_GID_ENV: &str = "OPENPENCIL_COLLAB_LOCATOR_HSM_PEER_GID";
|
pub const LOCATOR_HSM_PEER_GID_ENV: &str = "OPENPENCIL_COLLAB_LOCATOR_HSM_PEER_GID";
|
||||||
pub const LOCATOR_HSM_TIMEOUT_MS_ENV: &str = "OPENPENCIL_COLLAB_LOCATOR_HSM_TIMEOUT_MS";
|
pub const LOCATOR_HSM_TIMEOUT_MS_ENV: &str = "OPENPENCIL_COLLAB_LOCATOR_HSM_TIMEOUT_MS";
|
||||||
|
|
@ -52,6 +53,12 @@ const MAX_HSM_TIMEOUT_MS: u64 = 5_000;
|
||||||
const MAX_AUTH_IN_FLIGHT: usize = 256;
|
const MAX_AUTH_IN_FLIGHT: usize = 256;
|
||||||
const MAX_RATE_PER_SECOND: u32 = 10_000;
|
const MAX_RATE_PER_SECOND: u32 = 10_000;
|
||||||
|
|
||||||
|
mod production_check;
|
||||||
|
pub use production_check::{check_production, ProductionLocatorCheckError};
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod production_check_tests;
|
||||||
|
|
||||||
pub struct ProductionLocatorConfig {
|
pub struct ProductionLocatorConfig {
|
||||||
server: LocatorServerConfig,
|
server: LocatorServerConfig,
|
||||||
home_region: RelayRegion,
|
home_region: RelayRegion,
|
||||||
|
|
|
||||||
|
|
@ -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" }
|
op-collab-relay-protocol = { path = "../op-collab-relay-protocol" }
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
|
sha2 = "0.10"
|
||||||
subtle = "2.6"
|
subtle = "2.6"
|
||||||
thiserror.workspace = true
|
thiserror.workspace = true
|
||||||
tokio = { version = "1", features = [
|
tokio = { version = "1", features = [
|
||||||
|
|
@ -40,3 +41,4 @@ libc = "0.2"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
op-auth-bridge = { path = "../op-auth-bridge", features = ["test-issuer"] }
|
op-auth-bridge = { path = "../op-auth-bridge", features = ["test-issuer"] }
|
||||||
|
tempfile = "3"
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ mod connection;
|
||||||
mod connection_reauth;
|
mod connection_reauth;
|
||||||
mod error;
|
mod error;
|
||||||
mod peer_quota;
|
mod peer_quota;
|
||||||
mod pinned_verifiers;
|
|
||||||
mod production;
|
mod production;
|
||||||
mod registry;
|
mod registry;
|
||||||
mod server;
|
mod server;
|
||||||
|
|
@ -16,15 +15,14 @@ pub use auth::{
|
||||||
};
|
};
|
||||||
pub use config::{ConfigError, RelayConfig};
|
pub use config::{ConfigError, RelayConfig};
|
||||||
pub use error::RelayServerError;
|
pub use error::RelayServerError;
|
||||||
pub use op_collab_policy_file::PinnedPolicyFileFetcher;
|
pub use op_collab_policy_file::{
|
||||||
pub use pinned_verifiers::{
|
PinnedEd25519LocatorVerifier, PinnedPolicyFileFetcher, PinnedVerifierError,
|
||||||
PinnedEd25519LocatorVerifier, PinnedVerifierError, MAX_PINNED_VERIFIER_KEYS,
|
MAX_PINNED_VERIFIER_KEYS, MAX_PINNED_VERIFIER_KEY_FILE_BYTES, PINNED_VERIFIER_KEY_FILE_VERSION,
|
||||||
MAX_PINNED_VERIFIER_KEY_FILE_BYTES, PINNED_VERIFIER_KEY_FILE_VERSION,
|
|
||||||
};
|
};
|
||||||
pub use production::{
|
pub use production::{
|
||||||
run_production, ProductionRelayAuthConfig, ProductionRelayAuthConfigError,
|
check_production, run_production, ProductionRelayAuthConfig, ProductionRelayAuthConfigError,
|
||||||
ProductionRelayError, HOME_REGION_ENV, LEGACY_TICKET_BEARER_ENV, LOCATOR_KEYS_FILE_ENV,
|
ProductionRelayCheckError, ProductionRelayError, HOME_REGION_ENV, LEGACY_TICKET_BEARER_ENV,
|
||||||
POLICY_MAX_AGE_ENV, RELAY_X25519_KEYS_FILE_ENV, TICKET_POLICY_FILE_ENV,
|
LOCATOR_KEYS_FILE_ENV, POLICY_MAX_AGE_ENV, RELAY_X25519_KEYS_FILE_ENV, TICKET_POLICY_FILE_ENV,
|
||||||
};
|
};
|
||||||
pub use server::{run, run_until, run_with_authenticator, run_with_authenticator_until};
|
pub use server::{run, run_until, run_with_authenticator, run_with_authenticator_until};
|
||||||
pub use x25519_boundary::{
|
pub use x25519_boundary::{
|
||||||
|
|
@ -36,6 +34,8 @@ pub use x25519_boundary::{
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod production_auth_tests;
|
mod production_auth_tests;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
mod production_check_tests;
|
||||||
|
#[cfg(test)]
|
||||||
mod production_config_tests;
|
mod production_config_tests;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests;
|
mod tests;
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
use std::ffi::OsStr;
|
use std::ffi::OsStr;
|
||||||
|
|
||||||
use op_collab_relay_server::{run, run_production, ProductionRelayAuthConfig, RelayConfig};
|
use op_collab_relay_server::{
|
||||||
|
check_production, run, run_production, ProductionRelayAuthConfig, RelayConfig,
|
||||||
|
};
|
||||||
use tracing_subscriber::EnvFilter;
|
use tracing_subscriber::EnvFilter;
|
||||||
|
|
||||||
const LOG_LEVEL_ENV: &str = "OPENPENCIL_COLLAB_RELAY_LOG_LEVEL";
|
const LOG_LEVEL_ENV: &str = "OPENPENCIL_COLLAB_RELAY_LOG_LEVEL";
|
||||||
|
|
@ -26,6 +28,18 @@ async fn main() {
|
||||||
std::process::exit(2);
|
std::process::exit(2);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
if launch_mode == LaunchMode::CheckProduction {
|
||||||
|
match check_production() {
|
||||||
|
Ok(()) => {
|
||||||
|
println!("ready");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
eprintln!("relay production check failed: {error}");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
let config = match RelayConfig::from_env() {
|
let config = match RelayConfig::from_env() {
|
||||||
Ok(config) => config,
|
Ok(config) => config,
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
|
|
@ -65,6 +79,7 @@ async fn main() {
|
||||||
.await
|
.await
|
||||||
.map_err(|error| Box::new(error) as Box<dyn std::error::Error>)
|
.map_err(|error| Box::new(error) as Box<dyn std::error::Error>)
|
||||||
}
|
}
|
||||||
|
LaunchMode::CheckProduction => unreachable!("handled before listener configuration"),
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Err(error) = result {
|
if let Err(error) = result {
|
||||||
|
|
@ -96,6 +111,7 @@ enum LaunchMode {
|
||||||
FailClosed,
|
FailClosed,
|
||||||
UnauthenticatedDev,
|
UnauthenticatedDev,
|
||||||
Production { allow_ticket_binding_only: bool },
|
Production { allow_ticket_binding_only: bool },
|
||||||
|
CheckProduction,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_args() -> Result<LaunchMode, CliError> {
|
fn parse_args() -> Result<LaunchMode, CliError> {
|
||||||
|
|
@ -110,6 +126,7 @@ where
|
||||||
let mut allow_unauthenticated_dev = false;
|
let mut allow_unauthenticated_dev = false;
|
||||||
let mut production = false;
|
let mut production = false;
|
||||||
let mut allow_ticket_binding_only = false;
|
let mut allow_ticket_binding_only = false;
|
||||||
|
let mut check_production = false;
|
||||||
for arg in args {
|
for arg in args {
|
||||||
match arg.as_ref() {
|
match arg.as_ref() {
|
||||||
"--allow-unauthenticated-dev" if !allow_unauthenticated_dev => {
|
"--allow-unauthenticated-dev" if !allow_unauthenticated_dev => {
|
||||||
|
|
@ -121,15 +138,20 @@ where
|
||||||
"--allow-ticket-binding-only" if !allow_ticket_binding_only => {
|
"--allow-ticket-binding-only" if !allow_ticket_binding_only => {
|
||||||
allow_ticket_binding_only = true;
|
allow_ticket_binding_only = true;
|
||||||
}
|
}
|
||||||
|
"--check-production" if !check_production => {
|
||||||
|
check_production = true;
|
||||||
|
}
|
||||||
"--help" | "-h" => {
|
"--help" | "-h" => {
|
||||||
println!(
|
println!(
|
||||||
"Usage: op-collab-relay-server [--production \
|
"Usage: op-collab-relay-server [--production \
|
||||||
[--allow-ticket-binding-only] | --allow-unauthenticated-dev]\n\
|
[--allow-ticket-binding-only] | --allow-unauthenticated-dev | \
|
||||||
|
--check-production]\n\
|
||||||
\n\
|
\n\
|
||||||
--production loads pinned ticket-policy, locator, region, and relay X25519\n\
|
--production loads pinned ticket-policy, locator, region, and relay X25519\n\
|
||||||
verifier configuration from OPENPENCIL_COLLAB_RELAY_* environment variables.\n\
|
verifier configuration from OPENPENCIL_COLLAB_RELAY_* environment variables.\n\
|
||||||
--allow-ticket-binding-only explicitly selects reduced assurance when no\n\
|
--allow-ticket-binding-only explicitly selects reduced assurance when no\n\
|
||||||
challenge-proof key is configured.\n\
|
challenge-proof key is configured.\n\
|
||||||
|
--check-production verifies the mounted production trust inputs and exits.\n\
|
||||||
The development flag enables capability-only routing without a ticket.\n\
|
The development flag enables capability-only routing without a ticket.\n\
|
||||||
With no mode flag the server fails closed."
|
With no mode flag the server fails closed."
|
||||||
);
|
);
|
||||||
|
|
@ -141,10 +163,15 @@ where
|
||||||
if allow_unauthenticated_dev && production {
|
if allow_unauthenticated_dev && production {
|
||||||
return Err(CliError::ConflictingModes);
|
return Err(CliError::ConflictingModes);
|
||||||
}
|
}
|
||||||
|
if check_production && (allow_unauthenticated_dev || production || allow_ticket_binding_only) {
|
||||||
|
return Err(CliError::ConflictingCheckMode);
|
||||||
|
}
|
||||||
if allow_ticket_binding_only && !production {
|
if allow_ticket_binding_only && !production {
|
||||||
return Err(CliError::BindingOnlyRequiresProduction);
|
return Err(CliError::BindingOnlyRequiresProduction);
|
||||||
}
|
}
|
||||||
Ok(if allow_unauthenticated_dev {
|
Ok(if check_production {
|
||||||
|
LaunchMode::CheckProduction
|
||||||
|
} else if allow_unauthenticated_dev {
|
||||||
LaunchMode::UnauthenticatedDev
|
LaunchMode::UnauthenticatedDev
|
||||||
} else if production {
|
} else if production {
|
||||||
LaunchMode::Production {
|
LaunchMode::Production {
|
||||||
|
|
@ -163,6 +190,8 @@ enum CliError {
|
||||||
ConflictingModes,
|
ConflictingModes,
|
||||||
#[error("--allow-ticket-binding-only requires --production")]
|
#[error("--allow-ticket-binding-only requires --production")]
|
||||||
BindingOnlyRequiresProduction,
|
BindingOnlyRequiresProduction,
|
||||||
|
#[error("--check-production cannot be combined with a server launch mode")]
|
||||||
|
ConflictingCheckMode,
|
||||||
#[error("OPENPENCIL_COLLAB_RELAY_LOG_LEVEL must be one of error, warn, info, or debug")]
|
#[error("OPENPENCIL_COLLAB_RELAY_LOG_LEVEL must be one of error, warn, info, or debug")]
|
||||||
InvalidLogLevel,
|
InvalidLogLevel,
|
||||||
}
|
}
|
||||||
|
|
@ -207,6 +236,22 @@ mod tests {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn production_check_is_a_standalone_mode() {
|
||||||
|
assert_eq!(
|
||||||
|
parse_arg_values(["--check-production"]),
|
||||||
|
Ok(LaunchMode::CheckProduction)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
parse_arg_values(["--check-production", "--production"]),
|
||||||
|
Err(CliError::ConflictingCheckMode)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
parse_arg_values(["--check-production", "--check-production"]),
|
||||||
|
Err(CliError::UnknownOrDuplicateArgument)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn relay_log_filter_is_crate_scoped_and_bounded() {
|
fn relay_log_filter_is_crate_scoped_and_bounded() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|
|
||||||
|
|
@ -5,18 +5,21 @@ use std::{
|
||||||
num::NonZeroU64,
|
num::NonZeroU64,
|
||||||
path::{Path, PathBuf},
|
path::{Path, PathBuf},
|
||||||
sync::Arc,
|
sync::Arc,
|
||||||
|
time::{SystemTime, UNIX_EPOCH},
|
||||||
};
|
};
|
||||||
|
|
||||||
use op_auth_bridge::{
|
use op_auth_bridge::{
|
||||||
CollabJwksCacheLimits, CollabJwksFetchError, CollabTicketVerifier, CollabVerifierConfig,
|
CollabJwksCacheLimits, CollabJwksFetchError, CollabTicketVerifier, CollabUnionPolicy,
|
||||||
CollabVerifierConfigError, DEFAULT_MAX_COLLAB_JWKS_BYTES,
|
CollabVerifierConfig, CollabVerifierConfigError, DEFAULT_MAX_COLLAB_JWKS_BYTES,
|
||||||
};
|
};
|
||||||
|
use op_collab_policy_file::read_bounded_regular_file;
|
||||||
use op_collab_relay_protocol::RelayRegion;
|
use op_collab_relay_protocol::RelayRegion;
|
||||||
|
use sha2::{Digest as _, Sha256};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
run_with_authenticator, CollabTicketRelayAuthenticator, PinnedEd25519LocatorVerifier,
|
run_with_authenticator, CollabTicketRelayAuthenticator, PinnedEd25519LocatorVerifier,
|
||||||
PinnedPolicyFileFetcher, PinnedVerifierError, PinnedX25519KeyError, PinnedX25519ProofBoundary,
|
PinnedPolicyFileFetcher, PinnedVerifierError, PinnedX25519KeyError, PinnedX25519ProofBoundary,
|
||||||
RelayConfig, RelayServerError,
|
RelayConfig, RelayServerError, RelayServerX25519ProofBoundary,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub const HOME_REGION_ENV: &str = "OPENPENCIL_COLLAB_RELAY_HOME_REGION";
|
pub const HOME_REGION_ENV: &str = "OPENPENCIL_COLLAB_RELAY_HOME_REGION";
|
||||||
|
|
@ -24,6 +27,7 @@ pub const TICKET_POLICY_FILE_ENV: &str = "OPENPENCIL_COLLAB_RELAY_TICKET_POLICY_
|
||||||
pub const LOCATOR_KEYS_FILE_ENV: &str = "OPENPENCIL_COLLAB_RELAY_LOCATOR_KEYS_FILE";
|
pub const LOCATOR_KEYS_FILE_ENV: &str = "OPENPENCIL_COLLAB_RELAY_LOCATOR_KEYS_FILE";
|
||||||
pub const RELAY_X25519_KEYS_FILE_ENV: &str = "OPENPENCIL_COLLAB_RELAY_X25519_KEYS_FILE";
|
pub const RELAY_X25519_KEYS_FILE_ENV: &str = "OPENPENCIL_COLLAB_RELAY_X25519_KEYS_FILE";
|
||||||
pub const POLICY_MAX_AGE_ENV: &str = "OPENPENCIL_COLLAB_RELAY_POLICY_MAX_AGE_SECONDS";
|
pub const POLICY_MAX_AGE_ENV: &str = "OPENPENCIL_COLLAB_RELAY_POLICY_MAX_AGE_SECONDS";
|
||||||
|
pub const EXPECTED_POLICY_SHA256_ENV: &str = "OPENPENCIL_COLLAB_EXPECTED_POLICY_SHA256";
|
||||||
/// Migration switch for the legacy full-collaboration-ticket relay bearer.
|
/// Migration switch for the legacy full-collaboration-ticket relay bearer.
|
||||||
///
|
///
|
||||||
/// `accept` (the default) dual-accepts the claim-minimized relay token and the
|
/// `accept` (the default) dual-accepts the claim-minimized relay token and the
|
||||||
|
|
@ -35,6 +39,91 @@ pub const LEGACY_TICKET_BEARER_ENV: &str = "OPENPENCIL_COLLAB_RELAY_LEGACY_TICKE
|
||||||
const DEFAULT_POLICY_MAX_AGE_SECONDS: u64 = 60;
|
const DEFAULT_POLICY_MAX_AGE_SECONDS: u64 = 60;
|
||||||
const MAX_POLICY_MAX_AGE_SECONDS: u64 = 60 * 60;
|
const MAX_POLICY_MAX_AGE_SECONDS: u64 = 60 * 60;
|
||||||
|
|
||||||
|
/// Verify every production trust input without opening a network listener.
|
||||||
|
///
|
||||||
|
/// The signed policy is parsed here, rather than merely opened, so the binary's
|
||||||
|
/// embedded union roots and generation fence are exercised before promotion.
|
||||||
|
pub fn check_production() -> Result<(), ProductionRelayCheckError> {
|
||||||
|
let now_unix_seconds = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.ok()
|
||||||
|
.map(|duration| duration.as_secs())
|
||||||
|
.filter(|now| *now != 0)
|
||||||
|
.ok_or(ProductionRelayCheckError::Clock)?;
|
||||||
|
let config = ProductionRelayAuthConfig::from_env(false)
|
||||||
|
.map_err(|_| ProductionRelayCheckError::Configuration)?;
|
||||||
|
let expected_policy_sha256 = expected_policy_sha256_from_env()?;
|
||||||
|
check_production_config_at(&config, now_unix_seconds, &expected_policy_sha256)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn check_production_config_at(
|
||||||
|
config: &ProductionRelayAuthConfig,
|
||||||
|
now_unix_seconds: u64,
|
||||||
|
expected_policy_sha256: &str,
|
||||||
|
) -> Result<(), ProductionRelayCheckError> {
|
||||||
|
let verifier_config = CollabVerifierConfig::production();
|
||||||
|
let policy_body =
|
||||||
|
read_bounded_regular_file(&config.ticket_policy_file, DEFAULT_MAX_COLLAB_JWKS_BYTES)
|
||||||
|
.map_err(|_| ProductionRelayCheckError::Policy)?;
|
||||||
|
if format!("{:x}", Sha256::digest(&policy_body)) != expected_policy_sha256 {
|
||||||
|
return Err(ProductionRelayCheckError::Policy);
|
||||||
|
}
|
||||||
|
CollabUnionPolicy::from_json(
|
||||||
|
&policy_body,
|
||||||
|
DEFAULT_MAX_COLLAB_JWKS_BYTES,
|
||||||
|
verifier_config.issuer(),
|
||||||
|
now_unix_seconds,
|
||||||
|
)
|
||||||
|
.map_err(|_| ProductionRelayCheckError::Policy)?;
|
||||||
|
PinnedEd25519LocatorVerifier::from_file(&config.locator_keys_file)
|
||||||
|
.map_err(|_| ProductionRelayCheckError::LocatorKeys)?;
|
||||||
|
let x25519_path = config
|
||||||
|
.relay_x25519_keys_file
|
||||||
|
.as_ref()
|
||||||
|
.ok_or(ProductionRelayCheckError::RelayX25519Keys)?;
|
||||||
|
let boundary = PinnedX25519ProofBoundary::from_file(x25519_path)
|
||||||
|
.map_err(|_| ProductionRelayCheckError::RelayX25519Keys)?;
|
||||||
|
boundary
|
||||||
|
.active_key_id()
|
||||||
|
.map_err(|_| ProductionRelayCheckError::RelayX25519Keys)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn expected_policy_sha256_from_env() -> Result<String, ProductionRelayCheckError> {
|
||||||
|
parse_expected_policy_sha256(env::var_os(EXPECTED_POLICY_SHA256_ENV))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn parse_expected_policy_sha256(
|
||||||
|
value: Option<OsString>,
|
||||||
|
) -> Result<String, ProductionRelayCheckError> {
|
||||||
|
let value = value
|
||||||
|
.ok_or(ProductionRelayCheckError::Configuration)?
|
||||||
|
.into_string()
|
||||||
|
.map_err(|_| ProductionRelayCheckError::Configuration)?;
|
||||||
|
if value.len() != 64
|
||||||
|
|| !value
|
||||||
|
.bytes()
|
||||||
|
.all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
|
||||||
|
{
|
||||||
|
return Err(ProductionRelayCheckError::Configuration);
|
||||||
|
}
|
||||||
|
Ok(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, thiserror::Error, PartialEq, Eq)]
|
||||||
|
pub enum ProductionRelayCheckError {
|
||||||
|
#[error("configuration")]
|
||||||
|
Configuration,
|
||||||
|
#[error("clock")]
|
||||||
|
Clock,
|
||||||
|
#[error("signed policy")]
|
||||||
|
Policy,
|
||||||
|
#[error("locator verification keys")]
|
||||||
|
LocatorKeys,
|
||||||
|
#[error("relay proof keys")]
|
||||||
|
RelayX25519Keys,
|
||||||
|
}
|
||||||
|
|
||||||
pub struct ProductionRelayAuthConfig {
|
pub struct ProductionRelayAuthConfig {
|
||||||
home_region: RelayRegion,
|
home_region: RelayRegion,
|
||||||
ticket_policy_file: PathBuf,
|
ticket_policy_file: PathBuf,
|
||||||
|
|
|
||||||
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_ISSUER`, and
|
||||||
`OPENPENCIL_COLLAB_POLICY_ENDPOINT` only from trusted process-startup
|
`OPENPENCIL_COLLAB_POLICY_ENDPOINT` only from trusted process-startup
|
||||||
configuration. Production fetches `/api/v1/collab/policy`; the envelope must
|
configuration. Production fetches `/api/v1/collab/policy`; the envelope must
|
||||||
verify under the offline Ed25519 root pinned into the open client. Endpoint-only
|
verify under an offline Ed25519 root pinned into the open client. The emergency
|
||||||
|
root transition is bounded by policy generation: the legacy union-policy root
|
||||||
|
is authorized only for generations 1-3, and the replacement union-policy root
|
||||||
|
only for generation 4 and later. The
|
||||||
|
client verifies every compile-time root and requires exactly one signature
|
||||||
|
match and exactly one generation-authorized match. A production environment
|
||||||
|
variable cannot add or replace a union-policy root. Endpoint-only
|
||||||
configuration, conflicting policy/JWKS endpoints, signature or issuer
|
configuration, conflicting policy/JWKS endpoints, signature or issuer
|
||||||
mismatch, inactive key metadata, generation rollback, and same-generation
|
mismatch, inactive key metadata, generation rollback, and same-generation
|
||||||
rewrites fail closed without a raw-JWKS fallback. The old
|
rewrites fail closed without a raw-JWKS fallback. The old
|
||||||
|
|
@ -160,16 +166,20 @@ store — the open-source tree contains no production endpoint, and a build
|
||||||
without the injection keeps the relay purely environment-configured. A
|
without the injection keeps the relay purely environment-configured. A
|
||||||
persisted user preference selects which hub serves the signed bootstrap
|
persisted user preference selects which hub serves the signed bootstrap
|
||||||
document, and — absent an override — which region an owner publishes in. Both
|
document, and — absent an override — which region an owner publishes in. Both
|
||||||
hubs serve the same signed document verified against the same embedded root,
|
hubs serve the same signed document verified against the same embedded root set,
|
||||||
so the preference is a reachability choice, not a trust choice. `OPENPENCIL_COLLAB_BOOTSTRAP_URL` remains as an
|
so the preference is a reachability choice, not a trust choice. `OPENPENCIL_COLLAB_BOOTSTRAP_URL` remains as an
|
||||||
operator override that wins when set and stays fail-closed on an invalid
|
operator override that wins when set and stays fail-closed on an invalid
|
||||||
value, and an owner may still pin
|
value, and an owner may still pin
|
||||||
`OPENPENCIL_COLLAB_RELAY_HOME_REGION=cn|global` as a local home selector that
|
`OPENPENCIL_COLLAB_RELAY_HOME_REGION=cn|global` as a local home selector that
|
||||||
overrides the preference. A guest obtains its home region only from the
|
overrides the preference. A guest obtains its home region only from the
|
||||||
signed invite or the region-tagged pairing code. The embedded
|
signed invite or the region-tagged pairing code. During the emergency
|
||||||
`openpencil-collab-root-v1` Ed25519 public key currently has the same bytes as
|
transition the embedded bootstrap root set contains the legacy
|
||||||
the collaboration union-policy root, but the two source constants are not yet
|
`openpencil-collab-union-root-v2` key and the independent
|
||||||
single-sourced; deployment and tests must not assume source-level coupling.
|
`openpencil-collab-bootstrap-root-v2` key. The envelope `kid` selects exactly
|
||||||
|
one of them before signature verification. The legacy root is authorized only
|
||||||
|
through bootstrap generation 2, while the successor root is authorized from
|
||||||
|
generation 3 onward. An unknown id, a signature made by the other root, or a
|
||||||
|
root outside its generation range fails closed.
|
||||||
|
|
||||||
The bootstrap URL must be HTTPS with the exact
|
The bootstrap URL must be HTTPS with the exact
|
||||||
`/api/v1/collaboration/bootstrap` path and no credentials, query, or fragment.
|
`/api/v1/collaboration/bootstrap` path and no credentials, query, or fragment.
|
||||||
|
|
@ -500,9 +510,10 @@ regional mirrors as rollback-risk events because the client has no durable
|
||||||
cross-endpoint global generation ledger without a valid cache. Production
|
cross-endpoint global generation ledger without a valid cache. Production
|
||||||
rollouts must publish one byte-identical envelope to domestic and overseas
|
rollouts must publish one byte-identical envelope to domestic and overseas
|
||||||
mirrors, advance the op-hub minimum-generation floor, and preserve overlapping
|
mirrors, advance the op-hub minimum-generation floor, and preserve overlapping
|
||||||
region keys inside the signed snapshot. Rotating the embedded root requires a
|
region keys inside the signed snapshot. Rotating the embedded root still
|
||||||
coordinated client-and-service release; the current production desktop does
|
requires a coordinated client-and-service release. The bounded dual-root set
|
||||||
not load additional roots from runtime configuration.
|
supports that rollout, but the production desktop does not load additional
|
||||||
|
roots from runtime configuration.
|
||||||
|
|
||||||
The invite's signed `home_region` is authoritative. Physical geolocation,
|
The invite's signed `home_region` is authoritative. Physical geolocation,
|
||||||
bootstrap mirror location, DNS answer, and edge ingress do not authorize a
|
bootstrap mirror location, DNS answer, and edge ingress do not authorize a
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue