Skip to content

Commit 045a8cc

Browse files
sbernauerNickLarsenNZTechassi
authored
feat!(stackable-certs): Support adding SAN entries (#1057)
* feat!(stackable-certs): Support adding SAN entries * changelog * changelog * Improve error * changelog * Update crates/stackable-certs/src/ca/mod.rs Co-authored-by: Nick <10092581+NickLarsenNZ@users.noreply.github.com> * Update crates/stackable-certs/CHANGELOG.md Co-authored-by: Nick <10092581+NickLarsenNZ@users.noreply.github.com> * Update crates/stackable-certs/CHANGELOG.md Co-authored-by: Nick <10092581+NickLarsenNZ@users.noreply.github.com> * lets try different formatting * changelog * Update crates/stackable-certs/src/ca/mod.rs Co-authored-by: Techassi <git@techassi.dev> * Update crates/stackable-certs/src/ca/mod.rs Co-authored-by: Techassi <git@techassi.dev> * expect message * expect instead of snafu * Update crates/stackable-certs/src/ca/mod.rs Co-authored-by: Nick <10092581+NickLarsenNZ@users.noreply.github.com> * lowercase error message * Airflow -> Product * fmt * Update crates/stackable-certs/src/ca/mod.rs Co-authored-by: Techassi <git@techassi.dev> --------- Co-authored-by: Nick <10092581+NickLarsenNZ@users.noreply.github.com> Co-authored-by: Techassi <git@techassi.dev>
1 parent b2bf0e8 commit 045a8cc

File tree

4 files changed

+126
-32
lines changed

4 files changed

+126
-32
lines changed

crates/stackable-certs/CHANGELOG.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,24 @@ All notable changes to this project will be documented in this file.
44

55
## [Unreleased]
66

7+
### Added
8+
9+
- Add the function `CertificateAuthority::ca_cert` to easily get the CA `Certificate` ([#1057]).
10+
11+
### Changed
12+
13+
- BREAKING: The functions `generate_leaf_certificate`, `generate_rsa_leaf_certificate` and
14+
`generate_ecdsa_leaf_certificate` of `CertificateAuthority` accept an additional parameter
15+
`subject_alterative_dns_names` ([#1057]).
16+
- The passed SANs are added to the generated certificate, this is needed when the HTTPS server is
17+
accessible on multiple DNS names and/or IPs.
18+
- Pass an empty list (`[]`) to keep the existing behavior.
19+
- BREAKING: Constants have been renamed/retyped ([#1057]):
20+
- `DEFAULT_CA_VALIDITY_SECONDS` has been renamed to `DEFAULT_CA_VALIDITY` and now is of type `stackable_operator::time::Duration`.
21+
- `ROOT_CA_SUBJECT` has been renamed to `SDP_ROOT_CA_SUBJECT`.
22+
23+
[#1057]: https://github.com/stackabletech/operator-rs/pull/1057
24+
725
## [0.3.1] - 2024-07-10
826

927
### Changed
Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
1+
use stackable_operator::time::Duration;
2+
13
/// The default CA validity time span of one hour (3600 seconds).
2-
pub const DEFAULT_CA_VALIDITY_SECONDS: u64 = 3600;
4+
pub const DEFAULT_CA_VALIDITY: Duration = Duration::from_hours_unchecked(1);
35

46
/// The root CA subject name containing only the common name.
5-
pub const ROOT_CA_SUBJECT: &str = "CN=Stackable Data Platform Internal CA";
7+
pub const SDP_ROOT_CA_SUBJECT: &str = "CN=Stackable Data Platform Internal CA";

crates/stackable-certs/src/ca/mod.rs

Lines changed: 98 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
//! Contains types and functions to generate and sign certificate authorities
22
//! (CAs).
3-
use std::str::FromStr;
3+
use std::{fmt::Debug, str::FromStr};
44

55
use const_oid::db::rfc5280::{ID_KP_CLIENT_AUTH, ID_KP_SERVER_AUTH};
66
use k8s_openapi::api::core::v1::Secret;
@@ -9,9 +9,10 @@ use snafu::{OptionExt, ResultExt, Snafu};
99
use stackable_operator::{client::Client, commons::secret::SecretReference, time::Duration};
1010
use tracing::{debug, instrument};
1111
use x509_cert::{
12+
Certificate,
1213
builder::{Builder, CertificateBuilder, Profile},
13-
der::{DecodePem, pem::LineEnding, referenced::OwnedToRef},
14-
ext::pkix::{AuthorityKeyIdentifier, ExtendedKeyUsage},
14+
der::{DecodePem, asn1::Ia5String, pem::LineEnding, referenced::OwnedToRef},
15+
ext::pkix::{AuthorityKeyIdentifier, ExtendedKeyUsage, SubjectAltName, name::GeneralName},
1516
name::Name,
1617
serial_number::SerialNumber,
1718
spki::{EncodePublicKey, SubjectPublicKeyInfoOwned},
@@ -66,14 +67,22 @@ pub enum Error {
6667

6768
#[snafu(display("failed to parse AuthorityKeyIdentifier"))]
6869
ParseAuthorityKeyIdentifier { source: x509_cert::der::Error },
70+
71+
#[snafu(display(
72+
"failed to parse subject alternative DNS name {subject_alternative_dns_name:?} as a Ia5 string"
73+
))]
74+
ParseSubjectAlternativeDnsName {
75+
subject_alternative_dns_name: String,
76+
source: x509_cert::der::Error,
77+
},
6978
}
7079

7180
/// Custom implementation of [`std::cmp::PartialEq`] because some inner types
7281
/// don't implement it.
7382
///
74-
/// Note that this implementation is restritced to testing because there is a
83+
/// Note that this implementation is restricted to testing because there is a
7584
/// variant that is impossible to compare, and will cause a panic if it is
76-
/// attemped.
85+
/// attempted.
7786
#[cfg(test)]
7887
impl PartialEq for Error {
7988
fn eq(&self, other: &Self) -> bool {
@@ -170,7 +179,7 @@ where
170179
/// These parameters include:
171180
///
172181
/// - a randomly generated serial number
173-
/// - a default validity of one hour (see [`DEFAULT_CA_VALIDITY_SECONDS`])
182+
/// - a default validity of one hour (see [`DEFAULT_CA_VALIDITY`])
174183
///
175184
/// The CA contains the public half of the provided `signing_key` and is
176185
/// signed by the private half of said key.
@@ -181,9 +190,8 @@ where
181190
#[instrument(name = "create_certificate_authority", skip(signing_key_pair))]
182191
pub fn new(signing_key_pair: S) -> Result<Self> {
183192
let serial_number = rand::random::<u64>();
184-
let validity = Duration::from_secs(DEFAULT_CA_VALIDITY_SECONDS);
185193

186-
Self::new_with(signing_key_pair, serial_number, validity)
194+
Self::new_with(signing_key_pair, serial_number, DEFAULT_CA_VALIDITY)
187195
}
188196

189197
/// Creates a new CA certificate.
@@ -200,9 +208,8 @@ where
200208
// We don't allow customization of the CA subject by callers. Every CA
201209
// created by us should contain the same subject consisting a common set
202210
// of distinguished names (DNs).
203-
let subject = Name::from_str(ROOT_CA_SUBJECT).context(ParseSubjectSnafu {
204-
subject: ROOT_CA_SUBJECT,
205-
})?;
211+
let subject = Name::from_str(SDP_ROOT_CA_SUBJECT)
212+
.expect("the SDP_ROOT_CA_SUBJECT must be a valid subject");
206213

207214
let spki_pem = signing_key_pair
208215
.verifying_key()
@@ -267,15 +274,16 @@ where
267274
/// authentication, because they include [`ID_KP_CLIENT_AUTH`] and
268275
/// [`ID_KP_SERVER_AUTH`] in the extended key usage extension.
269276
///
270-
/// It is also possible to directly greate RSA or ECDSA-based leaf
277+
/// It is also possible to directly create RSA or ECDSA-based leaf
271278
/// certificates using [`CertificateAuthority::generate_rsa_leaf_certificate`]
272279
/// and [`CertificateAuthority::generate_ecdsa_leaf_certificate`].
273280
#[instrument(skip(self, key_pair))]
274-
pub fn generate_leaf_certificate<T>(
281+
pub fn generate_leaf_certificate<'a, T>(
275282
&mut self,
276283
key_pair: T,
277284
name: &str,
278285
scope: &str,
286+
subject_alterative_dns_names: impl IntoIterator<Item = &'a str> + Debug,
279287
validity: Duration,
280288
) -> Result<CertificatePair<T>>
281289
where
@@ -301,10 +309,6 @@ where
301309
let spki = SubjectPublicKeyInfoOwned::from_pem(spki_pem.as_bytes())
302310
.context(DecodeSpkiFromPemSnafu)?;
303311

304-
// The leaf certificate can be used for WWW client and server
305-
// authentication. This is a base requirement for TLS certs.
306-
let eku = ExtendedKeyUsage(vec![ID_KP_CLIENT_AUTH, ID_KP_SERVER_AUTH]);
307-
308312
let signer = self.certificate_pair.key_pair.signing_key();
309313
let mut builder = CertificateBuilder::new(
310314
Profile::Leaf {
@@ -325,9 +329,27 @@ where
325329
)
326330
.context(CreateCertificateBuilderSnafu)?;
327331

328-
// Again, add the extension created above.
332+
// The leaf certificate can be used for WWW client and server
333+
// authentication. This is a base requirement for TLS certs.
334+
builder
335+
.add_extension(&ExtendedKeyUsage(vec![
336+
ID_KP_CLIENT_AUTH,
337+
ID_KP_SERVER_AUTH,
338+
]))
339+
.context(AddCertificateExtensionSnafu)?;
340+
341+
let sans = subject_alterative_dns_names
342+
.into_iter()
343+
.map(|dns_name| {
344+
let ia5_dns_name =
345+
Ia5String::new(dns_name).context(ParseSubjectAlternativeDnsNameSnafu {
346+
subject_alternative_dns_name: dns_name.to_string(),
347+
})?;
348+
Ok(GeneralName::DnsName(ia5_dns_name))
349+
})
350+
.collect::<Result<Vec<_>, Error>>()?;
329351
builder
330-
.add_extension(&eku)
352+
.add_extension(&SubjectAltName(sans))
331353
.context(AddCertificateExtensionSnafu)?;
332354

333355
debug!("create and sign leaf certificate");
@@ -344,29 +366,31 @@ where
344366
/// See [`CertificateAuthority::generate_leaf_certificate`] for more
345367
/// information.
346368
#[instrument(skip(self))]
347-
pub fn generate_rsa_leaf_certificate(
369+
pub fn generate_rsa_leaf_certificate<'a>(
348370
&mut self,
349371
name: &str,
350372
scope: &str,
373+
subject_alterative_dns_names: impl IntoIterator<Item = &'a str> + Debug,
351374
validity: Duration,
352375
) -> Result<CertificatePair<rsa::SigningKey>> {
353376
let key = rsa::SigningKey::new().context(GenerateRsaSigningKeySnafu)?;
354-
self.generate_leaf_certificate(key, name, scope, validity)
377+
self.generate_leaf_certificate(key, name, scope, subject_alterative_dns_names, validity)
355378
}
356379

357380
/// Generates an ECDSAasync -based leaf certificate which is signed by this CA.
358381
///
359382
/// See [`CertificateAuthority::generate_leaf_certificate`] for more
360383
/// information.
361384
#[instrument(skip(self))]
362-
pub fn generate_ecdsa_leaf_certificate(
385+
pub fn generate_ecdsa_leaf_certificate<'a>(
363386
&mut self,
364387
name: &str,
365388
scope: &str,
389+
subject_alterative_dns_names: impl IntoIterator<Item = &'a str> + Debug,
366390
validity: Duration,
367391
) -> Result<CertificatePair<ecdsa::SigningKey>> {
368392
let key = ecdsa::SigningKey::new().context(GenerateEcdsaSigningKeySnafu)?;
369-
self.generate_leaf_certificate(key, name, scope, validity)
393+
self.generate_leaf_certificate(key, name, scope, subject_alterative_dns_names, validity)
370394
}
371395

372396
/// Create a [`CertificateAuthority`] from a Kubernetes [`Secret`].
@@ -443,6 +467,11 @@ where
443467

444468
Self::from_secret(secret, key_certificate, key_private_key)
445469
}
470+
471+
/// Returns the ca certificate.
472+
pub fn ca_cert(&self) -> &Certificate {
473+
&self.certificate_pair.certificate
474+
}
446475
}
447476

448477
impl CertificateAuthority<rsa::SigningKey> {
@@ -468,19 +497,61 @@ fn format_leaf_certificate_subject(name: &str, scope: &str) -> Result<Name> {
468497

469498
#[cfg(test)]
470499
mod tests {
500+
use const_oid::ObjectIdentifier;
501+
471502
use super::*;
472503

504+
const TEST_CERT_LIFETIME: Duration = Duration::from_hours_unchecked(1);
505+
const TEST_SAN: &str = "product-0.product.default.svc.cluster.local";
506+
473507
#[tokio::test]
474508
async fn rsa_key_generation() {
475509
let mut ca = CertificateAuthority::new_rsa().unwrap();
476-
ca.generate_rsa_leaf_certificate("Airflow", "pod", Duration::from_secs(3600))
477-
.unwrap();
510+
let cert = ca
511+
.generate_rsa_leaf_certificate("Product", "pod", [TEST_SAN], TEST_CERT_LIFETIME)
512+
.expect(
513+
"Must be able to generate an RSA certificate. Perhaps there was an RNG failure",
514+
);
515+
516+
assert_cert_attributes(cert.certificate());
478517
}
479518

480519
#[tokio::test]
481520
async fn ecdsa_key_generation() {
482521
let mut ca = CertificateAuthority::new_ecdsa().unwrap();
483-
ca.generate_ecdsa_leaf_certificate("Airflow", "pod", Duration::from_secs(3600))
484-
.unwrap();
522+
let cert = ca
523+
.generate_ecdsa_leaf_certificate("Product", "pod", [TEST_SAN], TEST_CERT_LIFETIME)
524+
.expect(
525+
"Must be able to generate an ECDSA certificate. Perhaps there was an RNG failure",
526+
);
527+
528+
assert_cert_attributes(cert.certificate());
529+
}
530+
531+
fn assert_cert_attributes(cert: &Certificate) {
532+
let cert = &cert.tbs_certificate;
533+
// Test subject
534+
assert_eq!(
535+
cert.subject,
536+
Name::from_str("CN=Product Certificate for pod").unwrap()
537+
);
538+
539+
// Test SAN extension is present
540+
let extensions = cert.extensions.as_ref().expect("cert must have extensions");
541+
assert!(
542+
extensions
543+
.iter()
544+
.any(|ext| ext.extn_id == ObjectIdentifier::new_unwrap("2.5.29.17"))
545+
);
546+
547+
// Test lifetime
548+
let not_before = cert.validity.not_before.to_system_time();
549+
let not_after = cert.validity.not_after.to_system_time();
550+
assert_eq!(
551+
not_after
552+
.duration_since(not_before)
553+
.expect("notBefore must be before notAfter"),
554+
*TEST_CERT_LIFETIME
555+
);
485556
}
486557
}

crates/stackable-webhook/src/tls.rs

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,11 @@ use hyper::{body::Incoming, service::service_fn};
88
use hyper_util::rt::{TokioExecutor, TokioIo};
99
use opentelemetry::trace::{FutureExt, SpanKind};
1010
use snafu::{ResultExt, Snafu};
11-
use stackable_certs::{CertificatePairError, ca::CertificateAuthority, keys::rsa};
12-
use stackable_operator::time::Duration;
11+
use stackable_certs::{
12+
CertificatePairError,
13+
ca::{CertificateAuthority, DEFAULT_CA_VALIDITY},
14+
keys::rsa,
15+
};
1316
use tokio::net::TcpListener;
1417
use tokio_rustls::{
1518
TlsAcceptor,
@@ -106,7 +109,7 @@ impl TlsServer {
106109
CertificateAuthority::new_rsa().context(CreateCertificateAuthoritySnafu)?;
107110

108111
let leaf_certificate = certificate_authority
109-
.generate_rsa_leaf_certificate("Leaf", "webhook", Duration::from_secs(3600))
112+
.generate_rsa_leaf_certificate("Leaf", "webhook", [], DEFAULT_CA_VALIDITY)
110113
.context(GenerateLeafCertificateSnafu)?;
111114

112115
let certificate_der = leaf_certificate

0 commit comments

Comments
 (0)