diff --git a/core/src/wire.rs b/core/src/wire.rs index 6963fb1..bbff678 100644 --- a/core/src/wire.rs +++ b/core/src/wire.rs @@ -18,13 +18,6 @@ fn bounded(value: &str, minimum: usize, maximum: usize) -> bool { (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 { match value { b'A'..=b'Z' => Some(value - b'A'), @@ -36,6 +29,74 @@ const fn base64url_value(value: u8) -> Option { } } +const fn base64_value(value: u8) -> Option { + 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 { let bytes = value.as_bytes(); 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.certificate_chain_pem, 1, 65_536) || !bounded(&credential.trust_bundle_pem, 1, 65_536) - || contains_private_key_pem(&credential.certificate_chain_pem) - || contains_private_key_pem(&credential.trust_bundle_pem) + || !certificate_only_pem(&credential.certificate_chain_pem) + || !certificate_only_pem(&credential.trust_bundle_pem) || timestamp(&credential.expires_at, false).is_none() { return Err(CoreError::InvalidArgument); diff --git a/core/tests/protocol_fixtures.rs b/core/tests/protocol_fixtures.rs index b645418..4939f84 100644 --- a/core/tests/protocol_fixtures.rs +++ b/core/tests/protocol_fixtures.rs @@ -72,6 +72,19 @@ fn capabilities() -> CapabilityProfile { .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 { + 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] fn strict_rc5_dtos_reject_duplicate_trailing_unknown_and_provider_fields() { 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") .expect("unexpired manifest"); - let credential = NativeTunnelCredential::decode( - br#"{"client_device_id":"device","device_key_id":"key","certificate_chain_pem":"certificate","trust_bundle_pem":"root","expires_at":"2099-01-01T00:00:00Z"}"#, - ) + let credential = NativeTunnelCredential::decode(&credential_json( + VALID_CERTIFICATE_PEM, + VALID_CERTIFICATE_PEM, + )) .expect("valid credential"); credential .validate_at("2026-08-12T00:00:00Z") .expect("unexpired credential"); 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()); @@ -155,27 +169,84 @@ fn rc5_manifest_credential_and_authority_enforce_bounds_and_bindings() { #[test] fn native_tunnel_credential_rejects_private_key_pem_in_certificate_fields() { 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"}"# - .to_owned() - .replacen( - &format!(r#""{field}":"{}""#, if field == "certificate_chain_pem" { "certificate" } else { "root" }), - &format!(r#""{field}":"-----BEGIN PRIVATE KEY-----\nsecret\n-----END PRIVATE KEY-----""#), - 1, - ); + let private_key = "-----BEGIN PRIVATE KEY-----\nAQID\n-----END PRIVATE KEY-----"; + let credential = if field == "certificate_chain_pem" { + credential_json(private_key, VALID_CERTIFICATE_PEM) + } else { + credential_json(VALID_CERTIFICATE_PEM, private_key) + }; assert!( - NativeTunnelCredential::decode(credential.as_bytes()).is_err(), + NativeTunnelCredential::decode(&credential).is_err(), "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] fn expiry_mismatch_and_capability_escalation_are_rejected() { let manifest = ConnectionManifest::decode(valid_manifest()).expect("valid manifest"); assert!(manifest.validate_at("2100-01-01T00:00:00Z").is_err()); - let credential = NativeTunnelCredential::decode( - br#"{"client_device_id":"device","device_key_id":"key","certificate_chain_pem":"certificate","trust_bundle_pem":"root","expires_at":"2099-01-01T00:00:00Z"}"#, - ) + let credential = NativeTunnelCredential::decode(&credential_json( + VALID_CERTIFICATE_PEM, + VALID_CERTIFICATE_PEM, + )) .expect("valid credential"); assert!(credential.validate_at("2099-01-01T00:00:00Z").is_err());