Skip to content

Commit

Permalink
deserialize_with public cert and private keys
Browse files Browse the repository at this point in the history
Use serde's deserialize_with to validate public cert and private keys,
causing failure if the DerEncodedKeyPair isn't valid. The visitor used
here is a fn from `String -> Result<String>` which allows for early
validation, and afterwards any code operating on DerEncodedKeyPair can
be sure it contains valid data.

Unfortunately, the deserialize_with could not be a fn from `String ->
an openssl::type` because the openssl types do not derive JsonSchema.

Previously some of this validation was in SiloSamlIdentityProvider's
validate, and that has been removed in this commit.
  • Loading branch information
jmpesp committed May 11, 2022
1 parent 5f352d0 commit fa10a10
Show file tree
Hide file tree
Showing 7 changed files with 310 additions and 27 deletions.
26 changes: 0 additions & 26 deletions nexus/src/authn/silos.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,32 +29,6 @@ impl SiloSamlIdentityProvider {
// check that there is a valid sign in url
let _sign_in_url = self.sign_in_url(None)?;

// if keys were supplied, check that both public and private are here
if self.public_cert_bytes()?.is_some()
&& self.private_key_bytes()?.is_none()
{
bail!("public and private key must be supplied together");
}
if self.public_cert_bytes()?.is_none()
&& self.private_key_bytes()?.is_some()
{
bail!("public and private key must be supplied together");
}

// If supplied, validate that the cert and key pair of [u8] is actually
// DER formatted X509 keys
if let Some(public_cert) = self.public_cert_bytes()? {
if openssl::x509::X509::from_der(&public_cert).is_err() {
bail!("public certificate must be DER formatted X509");
}
}

if let Some(private_key) = self.private_key_bytes()? {
if openssl::pkey::PKey::private_key_from_der(&private_key).is_err() {
bail!("private key must be DER formatted");
}
}

Ok(())
}

Expand Down
126 changes: 125 additions & 1 deletion nexus/src/external_api/params.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ use omicron_common::api::external::{
InstanceCpuCount, Ipv4Net, Ipv6Net, Name,
};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use serde::{de::{self, Visitor}, Deserialize, Deserializer, Serialize};
use std::collections::BTreeMap;
use std::net::IpAddr;
use uuid::Uuid;
Expand All @@ -30,12 +30,79 @@ pub struct SiloCreate {
#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)]
pub struct DerEncodedKeyPair {
/// request signing public certificate (base64 encoded der file)
#[serde(deserialize_with = "x509_cert_from_base64_encoded_der")]
pub public_cert: String,

/// request signing private key (base64 encoded der file)
#[serde(deserialize_with = "key_from_base64_encoded_der")]
pub private_key: String,
}

struct X509CertVisitor;

impl<'de> Visitor<'de> for X509CertVisitor {
type Value = String;

fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
formatter.write_str("a DER formatted X509 certificate as a string of base64 encoded bytes")
}

fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
where
E: de::Error,
{
let raw_bytes = base64::decode(&value.as_bytes())
.map_err(|e| de::Error::custom(format!("could not base64 decode public_cert: {}", e)))?;
let _parsed = openssl::x509::X509::from_der(&raw_bytes)
.map_err(|e| de::Error::custom(format!("public_cert is not recognized as a X509 certificate: {}", e)))?;

Ok(value.to_string())
}
}

fn x509_cert_from_base64_encoded_der<'de, D>(deserializer: D) -> Result<String, D::Error>
where
D: Deserializer<'de>,
{
deserializer.deserialize_str(X509CertVisitor)
}

struct KeyVisitor;

impl<'de> Visitor<'de> for KeyVisitor {
type Value = String;

fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
formatter.write_str("a DER formatted key as a string of base64 encoded bytes")
}

fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
where
E: de::Error,
{
let raw_bytes = base64::decode(&value)
.map_err(|e| de::Error::custom(format!("could not base64 decode private_key: {}", e)))?;

// TODO: samael does not support ECDSA, update to generic PKey type when it does
//let _parsed = openssl::pkey::PKey::private_key_from_der(&raw_bytes)
// .map_err(|e| de::Error::custom(format!("could not base64 decode private_key: {}", e)))?;

let parsed = openssl::rsa::Rsa::private_key_from_der(&raw_bytes)
.map_err(|e| de::Error::custom(format!("private_key is not recognized as a RSA private key: {}", e)))?;
let _parsed = openssl::pkey::PKey::from_rsa(parsed)
.map_err(|e| de::Error::custom(format!("private_key is not recognized as a RSA private key: {}", e)))?;

Ok(value.to_string())
}
}

fn key_from_base64_encoded_der<'de, D>(deserializer: D) -> Result<String, D::Error>
where
D: Deserializer<'de>,
{
deserializer.deserialize_str(KeyVisitor)
}

#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)]
pub struct SiloSamlIdentityProviderCreate {
#[serde(flatten)]
Expand All @@ -60,9 +127,66 @@ pub struct SiloSamlIdentityProviderCreate {
pub technical_contact_email: String,

/// optional request signing key pair
#[serde(deserialize_with = "validate_key_pair")]
pub signing_keypair: Option<DerEncodedKeyPair>,
}

/// sign some junk data and validate it with the key pair
fn sign_junk_data(key_pair: &DerEncodedKeyPair) -> Result<(), anyhow::Error> {
let private_key = {
let raw_bytes = base64::decode(&key_pair.private_key)?;
// TODO: samael does not support ECDSA, update to generic PKey type when it does
//let parsed = openssl::pkey::PKey::private_key_from_der(&raw_bytes)?;
let parsed = openssl::rsa::Rsa::private_key_from_der(&raw_bytes)?;
let parsed = openssl::pkey::PKey::from_rsa(parsed)?;
parsed
};

let public_key = {
let raw_bytes = base64::decode(&key_pair.public_cert)?;
let parsed = openssl::x509::X509::from_der(&raw_bytes)?;
parsed.public_key()?
};

let mut signer = openssl::sign::Signer::new(
openssl::hash::MessageDigest::sha256(), &private_key.as_ref(),
)?;

let some_junk_data = b"this is some junk data";

signer.update(some_junk_data)?;
let signature = signer.sign_to_vec()?;

let mut verifier = openssl::sign::Verifier::new(
openssl::hash::MessageDigest::sha256(),
&public_key,
)?;

verifier.update(some_junk_data)?;

if !verifier.verify(&signature)? {
anyhow::bail!("signature validation failed!");
}

Ok(())
}

fn validate_key_pair<'de, D>(deserializer: D) -> Result<Option<DerEncodedKeyPair>, D::Error>
where D: Deserializer<'de>
{
let v = Option::<DerEncodedKeyPair>::deserialize(deserializer)?;

if let Some(ref key_pair) = v {
if let Err(e) = sign_junk_data(&key_pair) {
return Err(de::Error::custom(
format!("data signed with key not verified with certificate! {}", e)
));
}
}

Ok(v)
}

// ORGANIZATIONS

/// Create-time parameters for an [`Organization`](crate::external_api::views::Organization)
Expand Down
1 change: 1 addition & 0 deletions nexus/tests/integration_tests/data/rsa-key-1-private.b64
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
MIIEpAIBAAKCAQEAp6RueynV/RybX8qsfh+DgUtFXIB3hNJaWNzpMkAGXPsmfycz5eBRpKr1kalTVde0HImBHMDH4ye/BXb7+KVUOxJSAzPOlXe5BLUvSBJ3h9zIrIAns9IFd785PJJJsFlEfaH6fb6TGlrfQSmrXCVKb17YIgw7miQNhoZFZG0+qEE6bmSu2zjtppX8/k4d5fCi/b5tdBvSy/GZAnBa6gweQeIH4Akt0jLZrUrpUY3GOLlZ/nVFhYwwaDMh2GMtZow44U2G1YhAkTTzBADk60+FXgrtPdzV2w2hnD5TU3pQAjVZikiRQw8fg8PVKCI8AZ9NFnsaDm1hpG/W7r1fste2dwIDAQABAoIBAQCYU+NH6qXUzk+oZSMDn2MA8wJdoSX4/KK3qFQFIwQlLNi4JUkVEhVdiTKGXtOoZs30OEWneMyobY83Sfx+3MuCuYzn+AU474ag7nm+BXmzbDyz8echkC8DtjAuB8cJhLOlbK+N3sMP6Y5/SXu5yPCv7gB6P59Q2n2nxQ38yP9sJhBmKqEJULDYvtgSz9dwwoymIF9XI1fnB+XnxMYrXpAfna9cJr4vgrB+xV+gtNJmNz+5+8UfDcWhkL9H622cOfFFgFc80ncFjpjHGueDef36uciQIdU6buvY15p3vgwYHC+KXkMGlciNSo1wKYRhk3+nSPYBenZPQz5cCXX1aisxAoGBAMjRtCGwbVBtmAKpkvOTvONwrfPWiSDT9GQqPUOT12GaxNPeCc/mt2sEUFfUlBtGt6P6KeLuGzY+m0veejwkIiPbNTsO8ocdneGSP7NTzD5hKkKcU7wVFKaxJgXB8yhl5wHYpcUUfiR+YJ0Oe2VsaHMxVgpA+veHGs6AiuJbfNQlAoGBANW09PWHGBdOMhXwpyPRjZSt7e5rQXMvkygsO1HNILtjXTWvYXMGwl9xutKHw3rEbmEKDG+NtGpgHDitIN1ZLu5p1MzKIOF5aZfE8tFSCTU0VaSUGHAIubLycJjRwpD8F9V6QhvwsWXlWOymuqvJbegCZFPYAMcv58YD8jiQQ29rAoGAZ2YSKYZ9wnurWTOWxnO7PiA2cOZ1lMGNhEV7ZeApdcgKsEwTIUjaB/Agrhh2adTvmS6lgoK24Cc8LsROi8jPC0dDETWRCqDlOc/jnKH49+VvrPxw4Na521o7CZvjZ1mQqBK0x9TVXlTzyeo6/u3ime09L+plTi3yT4FAAWy5yUECgYEAmbNXRsuN4R0lWrBFlbZebKOXb5WGcjCyVv9Q/qlYtE1nuXfUz6T54Slr44Uva7mhZXuTrBuvuZ48Ter+qxQ8c8579XoeoevvrO9CcJfe9XwZaI/274TnAjPqFY8vr5UQE0KmD3BSNmX4SeQ0d98cg/RMchz1mkzzFnC6IkJnrdcCgYBHx123AEMwDOBp5iiGFyGuNI7+7p8tVLoEQkW8fpZ4B4IDwmu88ccOVMZyacrcmhysVDFby2xlQC1aL9avOESmjyJcKmfgfBkTCQTMhSgrn5rPfkZlPp2TtEkxXWmgCaZz1Rvn6ZoAgqVA/bnhpgUfyTTNlEN76IaXIm1A8YnP/w==
1 change: 1 addition & 0 deletions nexus/tests/integration_tests/data/rsa-key-1-public.b64
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
MIIDXDCCAkSgAwIBAgIUHf/dNzLiRs3w3l0/Wlc/mBfH5/cwDQYJKoZIhvcNAQELBQAwRjELMAkGA1UEBhMCVVMxCzAJBgNVBAgTAkNBMRYwFAYDVQQHEw1TYW4gRnJhbmNpc2NvMRIwEAYDVQQDEwlzYW1sLnRlc3QwHhcNMjIwNTEwMTk0OTAwWhcNMjIwNTEwMTk1MDAwWjBGMQswCQYDVQQGEwJVUzELMAkGA1UECBMCQ0ExFjAUBgNVBAcTDVNhbiBGcmFuY2lzY28xEjAQBgNVBAMTCXNhbWwudGVzdDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAKekbnsp1f0cm1/KrH4fg4FLRVyAd4TSWljc6TJABlz7Jn8nM+XgUaSq9ZGpU1XXtByJgRzAx+MnvwV2+/ilVDsSUgMzzpV3uQS1L0gSd4fcyKyAJ7PSBXe/OTySSbBZRH2h+n2+kxpa30Epq1wlSm9e2CIMO5okDYaGRWRtPqhBOm5krts47aaV/P5OHeXwov2+bXQb0svxmQJwWuoMHkHiB+AJLdIy2a1K6VGNxji5Wf51RYWMMGgzIdhjLWaMOOFNhtWIQJE08wQA5OtPhV4K7T3c1dsNoZw+U1N6UAI1WYpIkUMPH4PD1SgiPAGfTRZ7Gg5tYaRv1u69X7LXtncCAwEAAaNCMEAwDgYDVR0PAQH/BAQDAgEGMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYEFKjukf8rhxLMo3MYYu5sfN1axY8SMA0GCSqGSIb3DQEBCwUAA4IBAQA/p2O/CGHp5EWEd3Yd47a24NLuX6ZZXNS+TlRmP0CSsq/vFKMXUiY9Q3p3GcuyHUux3/wPtZCLxgPcmE/m/3SWzHHKB+yEyqEzDJqHVKUZ8Y5tfXAHdXmEHkALpvLsWBNvde4HGFCZBQBiA4gTsi2qT6VfGM5OSa4HHWX8RURiMxjiE7Hz7KM+ZipJGsXfIKqMeeBx0Ke1Q7X3aM/ugIdBkY+tJd2MtqyPU5yqDJFZvrb0yV6uRdyS4AYIJ0x7pfMQWxz9S1LqQn2Cl2pab+EDiJtsmrjZTBlgG2rJ1p4PDBUbi8dChUjJnigFgwhTS5SI3iUMOWjsA3CXEFnlpgXM
1 change: 1 addition & 0 deletions nexus/tests/integration_tests/data/rsa-key-2-private.b64
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
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
1 change: 1 addition & 0 deletions nexus/tests/integration_tests/data/rsa-key-2-public.b64
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
MIIFXDCCA0SgAwIBAgIUVLhqPsB0pkEG1OklqGpKYobV7WQwDQYJKoZIhvcNAQENBQAwRjELMAkGA1UEBhMCVVMxCzAJBgNVBAgTAkNBMRYwFAYDVQQHEw1TYW4gRnJhbmNpc2NvMRIwEAYDVQQDEwlzYW1sLnRlc3QwHhcNMjIwNTExMTQ0NjAwWhcNMjcwNTEwMTQ0NjAwWjBGMQswCQYDVQQGEwJVUzELMAkGA1UECBMCQ0ExFjAUBgNVBAcTDVNhbiBGcmFuY2lzY28xEjAQBgNVBAMTCXNhbWwudGVzdDCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAKWBgx1IMIQNyu19c43QXW2WxPfJwlI8jsNJWwh+kb02lkCO5K4jCZW+qXneQtEP0bzkTPq25GRId3rtcdXZ3GlPc9Ag0+27seuRx3+TNdu5qw2edSwDIViNrb/iq2mVDUu2gmL9py17hdqe8+89shFuOgkZobcYsI72NBYzdoEYI3B0+nsHhS8zqPMt2+WEd8xbKItcLrA1cKCdNFgnbuHhCzoZXYzz/r1rh2yldrI0QvS3QKRBfj8xd9uZ7YQVJqzqodtbDpbbYXcPMJjOi3++1GalClMuVDDyd2naWUBEz9gJOiEiid2qqw2j7g3ToKM7PRGStQ+suzhsKZdbgbdoBJhgELEPZR9XvmlcbFgElXYvuRPBiUgkuWBIYoCFFof1cmKwZ7VQyQ7OYJP+b2Pl1UNJcNmnLpKW3luhhVmKN884gQh5vknUP9DoVYEVHhFqozxdfghhZnToplCj6faGD+NL+9jT20WvFgdpFWXFV6sXKhuEUCvlacHCnlk7SNpZ73yjsKH3j4zc3NSTK7QVe4n5UFgekQ/qoLw3HlFl+T91RnUH3GRK/rIQbZHJzzoA7ESk8SeXAnkFxSy9GdkSNJQdtnarP9X4/+8H3etiQYSQSPMXLYX84LPAjSiyII9TrUv9vsYjVKO2b+pd1EAm8aPHYacwAG9zB6iCekAFAgMBAAGjQjBAMA4GA1UdDwEB/wQEAwIBBjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBTR+MrnWfeQAPfMauQIbEGxkfZirjANBgkqhkiG9w0BAQ0FAAOCAgEATC0sjis5aQNZBYSS5TDHG/RHHHxuvBgErcpiNwjlh+J/s1fbBRdK8zTuxYulMvKi5PMOtwSKWhcDR1xX7gx+1Ldfh4ss0VO9JJBxKLz3B8y7EybdVMJioZ7eeYGUmpJXNqdtiuRzqUDADIiQRcyLymwyMyXpFG+tW26m5jSUYhsnYMJFYKUQo8wENrrETbQ7oJjEfDjAOQNiCKv4kCjP3ImFcXNFGqItzGvEZGUL7n6IiZvPE/ML2+CVgWTKSq7uoyvMtkHETaGq1uElxxT2Wi/zbIHltx6KOkugUJeeGhiEKztyMOFs1Lw712MYhzz8wG06j7bsZ8gDdiAlizqeSGU65NouSWzv+y7QHbxeWQB9CzC63SDVL3Ky2auB8WkbIUcZTM8N+71WRSAaco/vJW0meZLiOlwz+XWKi6f71MVZW1/8Lhv8goqKxVcALuTXziIg5lPhLaIiwsoMO/n2nyGlkr/lpnWd8Nhj6d/QB250zvj8x3SHEUdCAQws6ZYDohhm1WIcp3MA+OMUYObtGS7BtN+eP+LvFkO8046dUtMJzCPf4HW28rcUhQToK8Gmc3qRvxsRxpUi9ATItLsm1Y/UQ2QHCpWCtOQc58aHw/LERffVU9y/8xf14pKPlwgw3T9dMNNvrh+KrJ+MRJ7UHmu+TTuWFo4/Mbn0Ka3qny8=
Loading

0 comments on commit fa10a10

Please sign in to comment.