Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add hostname validation to certs uploaded to nexus #4100

Merged
merged 7 commits into from
Sep 20, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 71 additions & 27 deletions certificates/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ use omicron_common::api::external::Error;
use openssl::asn1::Asn1Time;
use openssl::pkey::PKey;
use openssl::x509::X509;
use std::borrow::Borrow;
use std::ffi::CString;

mod openssl_ext;
Expand Down Expand Up @@ -38,7 +39,7 @@ pub enum CertificateError {
#[error("Error validating certificate hostname")]
ErrorValidatingHostname(#[source] openssl::error::ErrorStack),

#[error("Certificate not valid for {hostname:?}: {cert_description:?}")]
#[error("Certificate not valid for given hostnames {hostname:?}: {cert_description}")]
NoDnsNameMatchingHostname { hostname: String, cert_description: String },

#[error("Unsupported certificate purpose (not usable for server auth)")]
Expand Down Expand Up @@ -103,15 +104,15 @@ impl CertificateValidator {
/// `key` is expected to be the private key for the leaf certificate of
/// `certs` in PEM format.
///
/// If `hostname` is not `None`, the leaf certificate of `certs` must be
/// valid for `hostname`, as determined by a dNSName entry in its subject
/// alternate names or (if there are no dNSName SANs) the cert's common
/// name.
pub fn validate(
/// If `possible_hostnames` is empty, no hostname validation is performed.
luqmana marked this conversation as resolved.
Show resolved Hide resolved
/// If `possible_hostnames` is not empty, we require _at least one_ of its
/// hostnames to match the SANs (or CN, if no SANs are present) of the leaf
/// certificate.
pub fn validate<S: Borrow<str>>(
&self,
certs: &[u8],
key: &[u8],
hostname: Option<&str>,
possible_hostnames: &[S],
) -> Result<(), CertificateError> {
// Checks on the certs themselves.
let mut certs = X509::stack_from_pem(certs)
Expand All @@ -134,16 +135,25 @@ impl CertificateValidator {
// to use with verifying the private key.
let cert = certs.swap_remove(0);

if let Some(hostname) = hostname {
let c_hostname = CString::new(hostname).map_err(|_| {
CertificateError::InvalidValidationHostname(
hostname.to_string(),
)
})?;
if !cert
.valid_for_hostname(&c_hostname)
.map_err(CertificateError::ErrorValidatingHostname)?
{
if !possible_hostnames.is_empty() {
let mut found_valid_hostname = false;
for hostname in possible_hostnames {
let hostname = hostname.borrow();
let c_hostname = CString::new(hostname).map_err(|_| {
CertificateError::InvalidValidationHostname(
hostname.to_string(),
)
})?;
luqmana marked this conversation as resolved.
Show resolved Hide resolved
if cert
.valid_for_hostname(&c_hostname)
.map_err(CertificateError::ErrorValidatingHostname)?
{
found_valid_hostname = true;
break;
}
}

if !found_valid_hostname {
let cert_description =
cert.hostname_description().unwrap_or_else(|err| {
format!(
Expand All @@ -152,7 +162,7 @@ impl CertificateValidator {
)
});
return Err(CertificateError::NoDnsNameMatchingHostname {
hostname: hostname.to_string(),
hostname: possible_hostnames.join(", "),
cert_description,
});
}
Expand Down Expand Up @@ -197,13 +207,13 @@ mod tests {

fn validate_cert_with_params(
params: CertificateParams,
hostname: Option<&str>,
possible_hostnames: &[&str],
) -> Result<(), CertificateError> {
let cert_chain = CertificateChain::with_params(params);
CertificateValidator::default().validate(
cert_chain.cert_chain_as_pem().as_bytes(),
cert_chain.end_cert_private_key_as_pem().as_bytes(),
hostname,
possible_hostnames,
)
}

Expand All @@ -218,7 +228,7 @@ mod tests {
let mut params = CertificateParams::new([]);
params.subject_alt_names =
vec![SanType::DnsName(dns_name.to_string())];
match validate_cert_with_params(params, Some(hostname)) {
match validate_cert_with_params(params, &[hostname]) {
Ok(()) => (),
Err(err) => panic!(
"certificate with SAN {dns_name} \
Expand All @@ -236,7 +246,7 @@ mod tests {
let mut params = CertificateParams::new([]);
params.subject_alt_names =
vec![SanType::DnsName(dns_name.to_string())];
match validate_cert_with_params(params, Some(server_hostname)) {
match validate_cert_with_params(params, &[server_hostname]) {
Ok(()) => panic!(
"certificate with SAN {dns_name} unexpectedly \
passed validation for hostname {server_hostname}"
Expand Down Expand Up @@ -269,7 +279,7 @@ mod tests {
let mut params = CertificateParams::new([]);
params.distinguished_name = dn;

match validate_cert_with_params(params, Some(hostname)) {
match validate_cert_with_params(params, &[hostname]) {
Ok(()) => (),
Err(err) => panic!(
"certificate with SAN {dns_name} \
Expand All @@ -289,7 +299,7 @@ mod tests {
let mut params = CertificateParams::new([]);
params.distinguished_name = dn;

match validate_cert_with_params(params, Some(server_hostname)) {
match validate_cert_with_params(params, &[server_hostname]) {
Ok(()) => panic!(
"certificate with SAN {dns_name} unexpectedly \
passed validation for hostname {server_hostname}"
Expand Down Expand Up @@ -326,7 +336,7 @@ mod tests {
params.subject_alt_names =
vec![SanType::DnsName(SUBJECT_ALT_NAME.to_string())];

match validate_cert_with_params(params, Some(HOSTNAME)) {
match validate_cert_with_params(params, &[HOSTNAME]) {
Ok(()) => panic!(
"certificate unexpectedly passed validation for hostname"
),
Expand All @@ -346,6 +356,40 @@ mod tests {
}
}

#[test]
fn cert_validated_if_any_possible_hostname_is_valid() {
// Expected-successful matches that contain a mix of valid and invalid
// possible hostnames
for (dns_name, hostnames) in &[
(
"oxide.computer",
// Since "any valid hostname" is allowed, an empty list of
// hostnames is also allowed
&[] as &[&str],
),
("oxide.computer", &["oxide.computer", "not-oxide.computer"]),
(
"*.oxide.computer",
&["*.oxide.computer", "foo.bar.oxide.computer"],
),
(
"*.oxide.computer",
&["foo.bar.not-oxide.computer", "foo.oxide.computer"],
),
] {
let mut params = CertificateParams::new([]);
params.subject_alt_names =
vec![SanType::DnsName(dns_name.to_string())];
match validate_cert_with_params(params, hostnames) {
Ok(()) => (),
Err(err) => panic!(
"certificate with SAN {dns_name} \
failed to validate for hostname {hostnames:?}: {err}"
),
}
}
}

#[test]
fn test_cert_extended_key_usage() {
const HOST: &str = "foo.oxide.computer";
Expand All @@ -371,7 +415,7 @@ mod tests {
params.extended_key_usages = ext_key_usage.clone();

assert!(
validate_cert_with_params(params, Some(HOST)).is_ok(),
validate_cert_with_params(params, &[HOST]).is_ok(),
"unexpected failure with {ext_key_usage:?}"
);
}
Expand All @@ -391,7 +435,7 @@ mod tests {

assert!(
matches!(
validate_cert_with_params(params, Some(HOST)),
validate_cert_with_params(params, &[HOST]),
Err(CertificateError::UnsupportedPurpose)
),
"unexpected success with {ext_key_usage:?}"
Expand Down
5 changes: 2 additions & 3 deletions nexus/db-model/src/certificate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,15 +45,14 @@ impl Certificate {
id: Uuid,
service: ServiceKind,
params: params::CertificateCreate,
possible_dns_names: &[String],
) -> Result<Self, CertificateError> {
let validator = CertificateValidator::default();

validator.validate(
params.cert.as_bytes(),
params.key.as_bytes(),
// TODO-correctness: We should pass a hostname here for cert
// validation: https://github.com/oxidecomputer/omicron/issues/4045
None,
possible_dns_names,
)?;

Ok(Self::new_unvalidated(silo_id, id, service, params))
Expand Down
19 changes: 15 additions & 4 deletions nexus/db-queries/src/db/datastore/dns.rs
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,18 @@ impl DataStore {
///
/// We do not generally expect there to be more than 1-2 DNS zones in a
/// group (and nothing today creates more than one).
async fn dns_zones_list_all<ConnErr>(
pub async fn dns_zones_list_all(
&self,
opctx: &OpContext,
dns_group: DnsGroup,
) -> ListResultVec<DnsZone> {
let conn = self.pool_authorized(opctx).await?;
self.dns_zones_list_all_on_connection(opctx, conn, dns_group).await
}

/// Variant of [`Self::dns_zones_list_all`] which may be called from a
/// transaction context.
pub(crate) async fn dns_zones_list_all_on_connection<ConnErr>(
&self,
opctx: &OpContext,
conn: &(impl async_bb8_diesel::AsyncConnection<
Expand Down Expand Up @@ -396,7 +407,6 @@ impl DataStore {
/// **Callers almost certainly want to wake up the corresponding Nexus
/// background task to cause these changes to be propagated to the
/// corresponding DNS servers.**
#[must_use]
pub async fn dns_update<ConnErr>(
&self,
opctx: &OpContext,
Expand All @@ -413,8 +423,9 @@ impl DataStore {
{
opctx.authorize(authz::Action::Modify, &authz::DNS_CONFIG).await?;

let zones =
self.dns_zones_list_all(opctx, conn, update.dns_group).await?;
let zones = self
.dns_zones_list_all_on_connection(opctx, conn, update.dns_group)
.await?;

let result = conn
.transaction_async(|c| async move {
Expand Down
38 changes: 32 additions & 6 deletions nexus/db-queries/src/db/datastore/rack.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ use async_bb8_diesel::PoolError;
use chrono::Utc;
use diesel::prelude::*;
use diesel::upsert::excluded;
use nexus_db_model::DnsGroup;
use nexus_db_model::DnsZone;
use nexus_db_model::ExternalIp;
use nexus_db_model::IncompleteNetworkInterface;
use nexus_db_model::InitialDnsGroup;
Expand Down Expand Up @@ -65,6 +67,7 @@ pub struct RackInit {
pub internal_dns: InitialDnsGroup,
pub external_dns: InitialDnsGroup,
pub recovery_silo: external_params::SiloCreate,
pub recovery_silo_fq_dns_name: String,
pub recovery_user_id: external_params::UserId,
pub recovery_user_password_hash: omicron_passwords::PasswordHashString,
pub dns_update: DnsVersionUpdateBuilder,
Expand Down Expand Up @@ -197,6 +200,7 @@ impl DataStore {
conn: &(impl AsyncConnection<DbConnection, ConnError> + Sync),
log: &slog::Logger,
recovery_silo: external_params::SiloCreate,
recovery_silo_fq_dns_name: String,
recovery_user_id: external_params::UserId,
recovery_user_password_hash: omicron_passwords::PasswordHashString,
dns_update: DnsVersionUpdateBuilder,
Expand All @@ -210,7 +214,14 @@ impl DataStore {
AsyncConnection<DbConnection, ConnError>,
{
let db_silo = self
.silo_create_conn(conn, opctx, opctx, recovery_silo, dns_update)
.silo_create_conn(
conn,
opctx,
opctx,
recovery_silo,
&[recovery_silo_fq_dns_name],
dns_update,
)
.await
.map_err(RackInitError::Silo)
.map_err(TxnError::CustomError)?;
Expand Down Expand Up @@ -521,6 +532,7 @@ impl DataStore {
&conn,
&log,
rack_init.recovery_silo,
rack_init.recovery_silo_fq_dns_name,
rack_init.recovery_user_id,
rack_init.recovery_user_password_hash,
rack_init.dns_update,
Expand Down Expand Up @@ -594,16 +606,16 @@ impl DataStore {
pub async fn nexus_external_addresses(
&self,
opctx: &OpContext,
) -> Result<Vec<IpAddr>, Error> {
) -> Result<(Vec<IpAddr>, Vec<DnsZone>), Error> {
opctx.authorize(authz::Action::Read, &authz::DNS_CONFIG).await?;

use crate::db::schema::external_ip::dsl as extip_dsl;
use crate::db::schema::service::dsl as service_dsl;
type TxnError = TransactionError<()>;
type TxnError = TransactionError<Error>;
self.pool_authorized(opctx)
.await?
.transaction_async(|conn| async move {
Ok(extip_dsl::external_ip
let ips = extip_dsl::external_ip
.inner_join(
service_dsl::service.on(service_dsl::id
.eq(extip_dsl::parent_id.assume_not_null())),
Expand All @@ -617,11 +629,21 @@ impl DataStore {
.await?
.into_iter()
.map(|external_ip| external_ip.ip.ip())
.collect())
.collect();

let dns_zones = self
.dns_zones_list_all_on_connection(
opctx,
&conn,
DnsGroup::External,
)
.await?;

Ok((ips, dns_zones))
})
.await
.map_err(|error: TxnError| match error {
TransactionError::CustomError(()) => unimplemented!(),
TransactionError::CustomError(err) => err,
TransactionError::Pool(e) => {
public_error_from_diesel_pool(e, ErrorHandler::Server)
}
Expand Down Expand Up @@ -699,6 +721,10 @@ mod test {
tls_certificates: vec![],
mapped_fleet_roles: Default::default(),
},
recovery_silo_fq_dns_name: format!(
"test-silo.sys.{}",
internal_dns::DNS_ZONE
),
recovery_user_id: "test-user".parse().unwrap(),
// empty string password
recovery_user_password_hash: "$argon2id$v=19$m=98304,t=13,\
Expand Down
4 changes: 4 additions & 0 deletions nexus/db-queries/src/db/datastore/silo.rs
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ impl DataStore {
opctx: &OpContext,
nexus_opctx: &OpContext,
new_silo_params: params::SiloCreate,
new_silo_dns_names: &[String],
dns_update: DnsVersionUpdateBuilder,
) -> CreateResult<Silo> {
let conn = self.pool_authorized(opctx).await?;
Expand All @@ -131,6 +132,7 @@ impl DataStore {
opctx,
nexus_opctx,
new_silo_params,
new_silo_dns_names,
dns_update,
)
.await
Expand All @@ -143,6 +145,7 @@ impl DataStore {
opctx: &OpContext,
nexus_opctx: &OpContext,
new_silo_params: params::SiloCreate,
new_silo_dns_names: &[String],
dns_update: DnsVersionUpdateBuilder,
) -> CreateResult<Silo>
where
Expand Down Expand Up @@ -253,6 +256,7 @@ impl DataStore {
Uuid::new_v4(),
ServiceKind::Nexus,
c,
new_silo_dns_names,
)
})
.collect::<Result<Vec<_>, _>>()
Expand Down
Loading