fix(core): require certificate-only PEM inputs
Verify Data Plane / gateway (push) Successful in 4m36s
Verify Data Plane / gateway (push) Successful in 4m36s
This commit is contained in:
+70
-9
@@ -18,13 +18,6 @@ fn bounded(value: &str, minimum: usize, maximum: usize) -> bool {
|
|||||||
(minimum..=maximum).contains(&value.len())
|
(minimum..=maximum).contains(&value.len())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn contains_private_key_pem(value: &str) -> bool {
|
|
||||||
value.lines().any(|line| {
|
|
||||||
let line = line.trim();
|
|
||||||
line.starts_with("-----BEGIN ") && line.ends_with("-----") && line.contains("PRIVATE KEY")
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const fn base64url_value(value: u8) -> Option<u8> {
|
const fn base64url_value(value: u8) -> Option<u8> {
|
||||||
match value {
|
match value {
|
||||||
b'A'..=b'Z' => Some(value - b'A'),
|
b'A'..=b'Z' => Some(value - b'A'),
|
||||||
@@ -36,6 +29,74 @@ const fn base64url_value(value: u8) -> Option<u8> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const fn base64_value(value: u8) -> Option<u8> {
|
||||||
|
match value {
|
||||||
|
b'A'..=b'Z' => Some(value - b'A'),
|
||||||
|
b'a'..=b'z' => Some(value - b'a' + 26),
|
||||||
|
b'0'..=b'9' => Some(value - b'0' + 52),
|
||||||
|
b'+' => Some(62),
|
||||||
|
b'/' => Some(63),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn valid_base64(value: &str) -> bool {
|
||||||
|
let bytes = value.as_bytes();
|
||||||
|
if bytes.is_empty() || !bytes.len().is_multiple_of(4) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let data_length = bytes
|
||||||
|
.iter()
|
||||||
|
.position(|byte| *byte == b'=')
|
||||||
|
.unwrap_or(bytes.len());
|
||||||
|
let padding = bytes.len() - data_length;
|
||||||
|
if data_length == 0
|
||||||
|
|| padding > 2
|
||||||
|
|| !bytes[..data_length]
|
||||||
|
.iter()
|
||||||
|
.all(|byte| base64_value(*byte).is_some())
|
||||||
|
|| !bytes[data_length..].iter().all(|byte| *byte == b'=')
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
match padding {
|
||||||
|
0 => true,
|
||||||
|
1 => base64_value(bytes[data_length - 1]).is_some_and(|value| value.trailing_zeros() >= 2),
|
||||||
|
2 => base64_value(bytes[data_length - 1]).is_some_and(|value| value.trailing_zeros() >= 4),
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn certificate_only_pem(value: &str) -> bool {
|
||||||
|
let mut lines = value.lines().peekable();
|
||||||
|
let mut blocks = 0_u32;
|
||||||
|
loop {
|
||||||
|
while lines.next_if(|line| line.trim().is_empty()).is_some() {}
|
||||||
|
let Some(begin) = lines.next() else {
|
||||||
|
return blocks > 0;
|
||||||
|
};
|
||||||
|
if begin != "-----BEGIN CERTIFICATE-----" {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
blocks += 1;
|
||||||
|
let mut body = String::new();
|
||||||
|
let mut complete = false;
|
||||||
|
for line in lines.by_ref() {
|
||||||
|
if line == "-----END CERTIFICATE-----" {
|
||||||
|
complete = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if line.is_empty() || line.trim() != line {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
body.push_str(line);
|
||||||
|
}
|
||||||
|
if !complete || !valid_base64(&body) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn raw_base64url_decoded_len(value: &str) -> Option<usize> {
|
fn raw_base64url_decoded_len(value: &str) -> Option<usize> {
|
||||||
let bytes = value.as_bytes();
|
let bytes = value.as_bytes();
|
||||||
if bytes.is_empty() || bytes.iter().any(|byte| base64url_value(*byte).is_none()) {
|
if bytes.is_empty() || bytes.iter().any(|byte| base64url_value(*byte).is_none()) {
|
||||||
@@ -395,8 +456,8 @@ impl NativeTunnelCredential {
|
|||||||
|| !bounded(&credential.device_key_id, 1, 128)
|
|| !bounded(&credential.device_key_id, 1, 128)
|
||||||
|| !bounded(&credential.certificate_chain_pem, 1, 65_536)
|
|| !bounded(&credential.certificate_chain_pem, 1, 65_536)
|
||||||
|| !bounded(&credential.trust_bundle_pem, 1, 65_536)
|
|| !bounded(&credential.trust_bundle_pem, 1, 65_536)
|
||||||
|| contains_private_key_pem(&credential.certificate_chain_pem)
|
|| !certificate_only_pem(&credential.certificate_chain_pem)
|
||||||
|| contains_private_key_pem(&credential.trust_bundle_pem)
|
|| !certificate_only_pem(&credential.trust_bundle_pem)
|
||||||
|| timestamp(&credential.expires_at, false).is_none()
|
|| timestamp(&credential.expires_at, false).is_none()
|
||||||
{
|
{
|
||||||
return Err(CoreError::InvalidArgument);
|
return Err(CoreError::InvalidArgument);
|
||||||
|
|||||||
@@ -72,6 +72,19 @@ fn capabilities() -> CapabilityProfile {
|
|||||||
.expect("literal capability profile is valid")
|
.expect("literal capability profile is valid")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const VALID_CERTIFICATE_PEM: &str = "-----BEGIN CERTIFICATE-----\nAQID\n-----END CERTIFICATE-----";
|
||||||
|
|
||||||
|
fn credential_json(certificate_chain_pem: &str, trust_bundle_pem: &str) -> Vec<u8> {
|
||||||
|
serde_json::to_vec(&serde_json::json!({
|
||||||
|
"client_device_id": "device",
|
||||||
|
"device_key_id": "key",
|
||||||
|
"certificate_chain_pem": certificate_chain_pem,
|
||||||
|
"trust_bundle_pem": trust_bundle_pem,
|
||||||
|
"expires_at": "2099-01-01T00:00:00Z",
|
||||||
|
}))
|
||||||
|
.expect("literal credential is JSON-encodable")
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn strict_rc5_dtos_reject_duplicate_trailing_unknown_and_provider_fields() {
|
fn strict_rc5_dtos_reject_duplicate_trailing_unknown_and_provider_fields() {
|
||||||
assert!(ConnectionManifest::decode(valid_manifest()).is_ok());
|
assert!(ConnectionManifest::decode(valid_manifest()).is_ok());
|
||||||
@@ -128,15 +141,16 @@ fn rc5_manifest_credential_and_authority_enforce_bounds_and_bindings() {
|
|||||||
.validate_at("2026-08-12T00:00:00Z")
|
.validate_at("2026-08-12T00:00:00Z")
|
||||||
.expect("unexpired manifest");
|
.expect("unexpired manifest");
|
||||||
|
|
||||||
let credential = NativeTunnelCredential::decode(
|
let credential = NativeTunnelCredential::decode(&credential_json(
|
||||||
br#"{"client_device_id":"device","device_key_id":"key","certificate_chain_pem":"certificate","trust_bundle_pem":"root","expires_at":"2099-01-01T00:00:00Z"}"#,
|
VALID_CERTIFICATE_PEM,
|
||||||
)
|
VALID_CERTIFICATE_PEM,
|
||||||
|
))
|
||||||
.expect("valid credential");
|
.expect("valid credential");
|
||||||
credential
|
credential
|
||||||
.validate_at("2026-08-12T00:00:00Z")
|
.validate_at("2026-08-12T00:00:00Z")
|
||||||
.expect("unexpired credential");
|
.expect("unexpired credential");
|
||||||
assert!(NativeTunnelCredential::decode(
|
assert!(NativeTunnelCredential::decode(
|
||||||
br#"{"client_device_id":"device","device_key_id":"key","certificate_chain_pem":"certificate","trust_bundle_pem":"root","client_private_key_pem":"forbidden","expires_at":"2099-01-01T00:00:00Z"}"#,
|
br#"{"client_device_id":"device","device_key_id":"key","certificate_chain_pem":"-----BEGIN CERTIFICATE-----\nAQID\n-----END CERTIFICATE-----","trust_bundle_pem":"-----BEGIN CERTIFICATE-----\nAQID\n-----END CERTIFICATE-----","client_private_key_pem":"forbidden","expires_at":"2099-01-01T00:00:00Z"}"#,
|
||||||
)
|
)
|
||||||
.is_err());
|
.is_err());
|
||||||
|
|
||||||
@@ -155,27 +169,84 @@ fn rc5_manifest_credential_and_authority_enforce_bounds_and_bindings() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn native_tunnel_credential_rejects_private_key_pem_in_certificate_fields() {
|
fn native_tunnel_credential_rejects_private_key_pem_in_certificate_fields() {
|
||||||
for field in ["certificate_chain_pem", "trust_bundle_pem"] {
|
for field in ["certificate_chain_pem", "trust_bundle_pem"] {
|
||||||
let credential = r#"{"client_device_id":"device","device_key_id":"key","certificate_chain_pem":"certificate","trust_bundle_pem":"root","expires_at":"2099-01-01T00:00:00Z"}"#
|
let private_key = "-----BEGIN PRIVATE KEY-----\nAQID\n-----END PRIVATE KEY-----";
|
||||||
.to_owned()
|
let credential = if field == "certificate_chain_pem" {
|
||||||
.replacen(
|
credential_json(private_key, VALID_CERTIFICATE_PEM)
|
||||||
&format!(r#""{field}":"{}""#, if field == "certificate_chain_pem" { "certificate" } else { "root" }),
|
} else {
|
||||||
&format!(r#""{field}":"-----BEGIN PRIVATE KEY-----\nsecret\n-----END PRIVATE KEY-----""#),
|
credential_json(VALID_CERTIFICATE_PEM, private_key)
|
||||||
1,
|
};
|
||||||
);
|
|
||||||
assert!(
|
assert!(
|
||||||
NativeTunnelCredential::decode(credential.as_bytes()).is_err(),
|
NativeTunnelCredential::decode(&credential).is_err(),
|
||||||
"private key armor accepted in {field}"
|
"private key armor accepted in {field}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn native_tunnel_credential_accepts_one_or_more_certificate_blocks() {
|
||||||
|
let two_certificates = format!("{VALID_CERTIFICATE_PEM}\n\n{VALID_CERTIFICATE_PEM}\n");
|
||||||
|
assert!(NativeTunnelCredential::decode(&credential_json(
|
||||||
|
&two_certificates,
|
||||||
|
VALID_CERTIFICATE_PEM,
|
||||||
|
))
|
||||||
|
.is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn assert_credential_pem_rejected(invalid_values: &[&str]) {
|
||||||
|
for invalid in invalid_values {
|
||||||
|
assert!(
|
||||||
|
NativeTunnelCredential::decode(&credential_json(invalid, VALID_CERTIFICATE_PEM))
|
||||||
|
.is_err(),
|
||||||
|
"invalid certificate chain accepted"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
NativeTunnelCredential::decode(&credential_json(VALID_CERTIFICATE_PEM, invalid))
|
||||||
|
.is_err(),
|
||||||
|
"invalid trust bundle accepted"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn native_tunnel_credential_rejects_bare_certificate_text() {
|
||||||
|
assert_credential_pem_rejected(&["certificate"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn native_tunnel_credential_rejects_non_certificate_pem_labels() {
|
||||||
|
assert_credential_pem_rejected(&["-----BEGIN PUBLIC KEY-----\nAQID\n-----END PUBLIC KEY-----"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn native_tunnel_credential_rejects_malformed_or_incomplete_certificate_armor() {
|
||||||
|
assert_credential_pem_rejected(&[
|
||||||
|
"-----BEGIN CERTIFICATE-----\nAQID",
|
||||||
|
"-----BEGIN CERTIFICATE-----\n!!!!\n-----END CERTIFICATE-----",
|
||||||
|
"-----BEGIN CERTIFICATE-----\nAQI\n-----END CERTIFICATE-----",
|
||||||
|
"-----BEGIN CERTIFICATE-----\nAQJ=\n-----END CERTIFICATE-----",
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn native_tunnel_credential_rejects_junk_between_or_after_certificate_blocks() {
|
||||||
|
let between = format!("{VALID_CERTIFICATE_PEM}\njunk\n{VALID_CERTIFICATE_PEM}");
|
||||||
|
let after = format!("{VALID_CERTIFICATE_PEM}\njunk");
|
||||||
|
assert_credential_pem_rejected(&[&between, &after]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn native_tunnel_credential_rejects_empty_certificate_blocks() {
|
||||||
|
assert_credential_pem_rejected(&["-----BEGIN CERTIFICATE-----\n-----END CERTIFICATE-----"]);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn expiry_mismatch_and_capability_escalation_are_rejected() {
|
fn expiry_mismatch_and_capability_escalation_are_rejected() {
|
||||||
let manifest = ConnectionManifest::decode(valid_manifest()).expect("valid manifest");
|
let manifest = ConnectionManifest::decode(valid_manifest()).expect("valid manifest");
|
||||||
assert!(manifest.validate_at("2100-01-01T00:00:00Z").is_err());
|
assert!(manifest.validate_at("2100-01-01T00:00:00Z").is_err());
|
||||||
let credential = NativeTunnelCredential::decode(
|
let credential = NativeTunnelCredential::decode(&credential_json(
|
||||||
br#"{"client_device_id":"device","device_key_id":"key","certificate_chain_pem":"certificate","trust_bundle_pem":"root","expires_at":"2099-01-01T00:00:00Z"}"#,
|
VALID_CERTIFICATE_PEM,
|
||||||
)
|
VALID_CERTIFICATE_PEM,
|
||||||
|
))
|
||||||
.expect("valid credential");
|
.expect("valid credential");
|
||||||
assert!(credential.validate_at("2099-01-01T00:00:00Z").is_err());
|
assert!(credential.validate_at("2099-01-01T00:00:00Z").is_err());
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user