Skip to content

Commit 88d850d

Browse files
committed
Rewrite CA and cert generation to use builder pattern
1 parent 6b79029 commit 88d850d

File tree

14 files changed

+821
-579
lines changed

14 files changed

+821
-579
lines changed

Cargo.lock

Lines changed: 26 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ repository = "https://github.com/stackabletech/operator-rs"
1212
product-config = { git = "https://github.com/stackabletech/product-config.git", tag = "0.7.0" }
1313

1414
axum = { version = "0.8.1", features = ["http2"] }
15+
bon = "3.6.3"
1516
chrono = { version = "0.4.38", default-features = false }
1617
clap = { version = "4.5.17", features = ["derive", "cargo", "env"] }
1718
const_format = "0.2.33"

crates/stackable-certs/CHANGELOG.md

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,14 @@ All notable changes to this project will be documented in this file.
66

77
### Added
88

9-
- BREAKING: `CertificateAuthority::generate_leaf_certificate` (and `generate_rsa_leaf_certificate` and `generate_ecdsa_leaf_certificate`)
10-
now take an additional parameter `subject_alterative_dns_names`. The passed SANs are added to the generated certificate,
11-
this is needed for basically all modern TLS certificate validations when used with HTTPS.
12-
Pass an empty list (`[]`) to keep the existing behavior ([#1044]).
9+
- Support adding SAN entries to generated certificates, this is needed for basically all modern TLS
10+
certificate validations when used with HTTPS ([#1044]).
1311
- BREAKING: The constant `DEFAULT_CA_VALIDITY_SECONDS` has been renamed to `DEFAULT_CA_VALIDITY` and now is of type `stackable_operator::time::Duration`.
1412
Also, the constant `ROOT_CA_SUBJECT` has been renamed to `SDP_ROOT_CA_SUBJECT` ([#1044]).
15-
- Added the function `CertificateAuthority::ca_cert` to easily get the CA `Certificate` ([#1044]).
13+
14+
### Changed
15+
16+
- GIGA BREAKING: Rewrite entire CA and cert generation to use a builder pattern ([#1044]).
1617

1718
## [0.3.1] - 2024-07-10
1819

crates/stackable-certs/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ rustls = ["dep:tokio-rustls"]
1313
[dependencies]
1414
stackable-operator = { path = "../stackable-operator" }
1515

16+
bon.workspace = true
1617
const-oid.workspace = true
1718
ecdsa.workspace = true
1819
k8s-openapi.workspace = true
Lines changed: 241 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,241 @@
1+
use bon::Builder;
2+
use rsa::pkcs8::EncodePublicKey;
3+
use snafu::{ResultExt, Snafu};
4+
use stackable_operator::time::Duration;
5+
use tracing::debug;
6+
use x509_cert::{
7+
builder::{Builder, CertificateBuilder, Profile},
8+
der::{DecodePem, referenced::OwnedToRef},
9+
ext::pkix::AuthorityKeyIdentifier,
10+
name::Name,
11+
serial_number::SerialNumber,
12+
spki::SubjectPublicKeyInfoOwned,
13+
time::Validity,
14+
};
15+
16+
use super::CertificateAuthority;
17+
use crate::{
18+
CertificatePair,
19+
ca::{DEFAULT_CA_VALIDITY, PEM_LINE_ENDING, SDP_ROOT_CA_SUBJECT},
20+
keys::CertificateKeypair,
21+
};
22+
23+
/// Defines all error variants which can occur when creating a CA
24+
#[derive(Debug, Snafu)]
25+
pub enum CreateCertificateAuthorityError<E>
26+
where
27+
E: std::error::Error + 'static,
28+
{
29+
#[snafu(display("failed to parse validity"))]
30+
ParseValidity { source: x509_cert::der::Error },
31+
32+
#[snafu(display("failed to parse \"{subject}\" as subject"))]
33+
ParseSubject {
34+
source: x509_cert::der::Error,
35+
subject: String,
36+
},
37+
38+
#[snafu(display("failed to create signing key pair"))]
39+
CreateSigningKeyPair { source: E },
40+
41+
#[snafu(display("failed to serialize public key as PEM"))]
42+
SerializePublicKey { source: x509_cert::spki::Error },
43+
44+
#[snafu(display("failed to decode SPKI from PEM"))]
45+
DecodeSpkiFromPem { source: x509_cert::der::Error },
46+
47+
#[snafu(display("failed to parse AuthorityKeyIdentifier"))]
48+
ParseAuthorityKeyIdentifier { source: x509_cert::der::Error },
49+
50+
#[snafu(display("failed to create certificate builder"))]
51+
CreateCertificateBuilder { source: x509_cert::builder::Error },
52+
53+
#[snafu(display("failed to add certificate extension"))]
54+
AddCertificateExtension { source: x509_cert::builder::Error },
55+
56+
#[snafu(display("failed to build certificate"))]
57+
BuildCertificate { source: x509_cert::builder::Error },
58+
}
59+
60+
/// This builder builds certificate authorities of type [`CertificateAuthority`].
61+
///
62+
/// Example code to construct a CA:
63+
///
64+
/// ```no_run
65+
/// use stackable_certs::{
66+
/// keys::ecdsa,
67+
/// ca::{CertificateAuthority, CertificateAuthorityBuilder},
68+
/// };
69+
///
70+
/// let ca: CertificateAuthority<ecdsa::SigningKey> = CertificateAuthorityBuilder::builder()
71+
/// .build()
72+
/// .build_ca()
73+
/// .expect("failed to build CA");
74+
/// ```
75+
#[derive(Builder)]
76+
pub struct CertificateAuthorityBuilder<'a, SKP>
77+
where
78+
SKP: CertificateKeypair,
79+
<SKP::SigningKey as signature::Keypair>::VerifyingKey: EncodePublicKey,
80+
{
81+
/// Required subject of the certificate authority, usually starts with `CN=`.
82+
#[builder(default = SDP_ROOT_CA_SUBJECT)]
83+
subject: &'a str,
84+
85+
/// Validity/lifetime of the certificate.
86+
///
87+
/// If not specified the default of [`DEFAULT_CA_VALIDITY`] will be used.
88+
#[builder(default = DEFAULT_CA_VALIDITY)]
89+
validity: Duration,
90+
91+
/// Serial number of the generated certificate.
92+
///
93+
/// If not specified a random serial will be generated.
94+
serial_number: Option<u64>,
95+
96+
/// Cryptographic keypair used to sign leaf certificates.
97+
///
98+
/// If not specified a random keypair will be generated.
99+
signing_key_pair: Option<SKP>,
100+
}
101+
102+
impl<SKP> CertificateAuthorityBuilder<'_, SKP>
103+
where
104+
SKP: CertificateKeypair,
105+
<SKP::SigningKey as signature::Keypair>::VerifyingKey: EncodePublicKey,
106+
{
107+
pub fn build_ca(
108+
self,
109+
) -> Result<CertificateAuthority<SKP>, CreateCertificateAuthorityError<SKP::Error>> {
110+
let serial_number =
111+
SerialNumber::from(self.serial_number.unwrap_or_else(|| rand::random::<u64>()));
112+
let validity = Validity::from_now(*self.validity).context(ParseValiditySnafu)?;
113+
let subject: Name = self.subject.parse().context(ParseSubjectSnafu {
114+
subject: self.subject,
115+
})?;
116+
let signing_key_pair = match self.signing_key_pair {
117+
Some(signing_key_pair) => signing_key_pair,
118+
None => SKP::new().context(CreateSigningKeyPairSnafu)?,
119+
};
120+
121+
let spki_pem = signing_key_pair
122+
.verifying_key()
123+
.to_public_key_pem(PEM_LINE_ENDING)
124+
.context(SerializePublicKeySnafu)?;
125+
126+
let spki = SubjectPublicKeyInfoOwned::from_pem(spki_pem.as_bytes())
127+
.context(DecodeSpkiFromPemSnafu)?;
128+
129+
// There are multiple default extensions included in the profile. For
130+
// the root profile, these are:
131+
//
132+
// - BasicConstraints marked as critical and CA = true
133+
// - SubjectKeyIdentifier with the 160-bit SHA-1 hash of the subject
134+
// public key.
135+
// - KeyUsage with KeyCertSign and CRLSign bits set. Ideally we also
136+
// want to include the DigitalSignature bit, which for example is
137+
// required for CA certs which want to sign an OCSP response.
138+
// Currently, the root profile doesn't include that bit.
139+
//
140+
// The root profile doesn't add the AuthorityKeyIdentifier extension.
141+
// We manually add it below by using the 160-bit SHA-1 hash of the
142+
// subject public key. This conforms to one of the outlined methods for
143+
// generating key identifiers outlined in RFC 5280, section 4.2.1.2.
144+
//
145+
// Prepare extensions so we can avoid clones.
146+
let aki = AuthorityKeyIdentifier::try_from(spki.owned_to_ref())
147+
.context(ParseAuthorityKeyIdentifierSnafu)?;
148+
149+
let signer = signing_key_pair.signing_key();
150+
let mut builder = CertificateBuilder::new(
151+
Profile::Root,
152+
serial_number,
153+
validity,
154+
subject,
155+
spki,
156+
signer,
157+
)
158+
.context(CreateCertificateBuilderSnafu)?;
159+
160+
// Add extension constructed above
161+
builder
162+
.add_extension(&aki)
163+
.context(AddCertificateExtensionSnafu)?;
164+
165+
debug!("create and sign CA certificate");
166+
let certificate = builder.build().context(BuildCertificateSnafu)?;
167+
168+
Ok(CertificateAuthority {
169+
certificate_pair: CertificatePair {
170+
certificate,
171+
key_pair: signing_key_pair,
172+
},
173+
})
174+
}
175+
}
176+
177+
#[cfg(test)]
178+
mod tests {
179+
use x509_cert::certificate::TbsCertificateInner;
180+
181+
use super::*;
182+
use crate::keys::{ecdsa, rsa};
183+
184+
#[test]
185+
fn minimal_ca() {
186+
let ca: CertificateAuthority<ecdsa::SigningKey> = CertificateAuthorityBuilder::builder()
187+
.build()
188+
.build_ca()
189+
.expect("failed to build CA");
190+
191+
assert_ca_cert_attributes(
192+
&ca.ca_cert().tbs_certificate,
193+
SDP_ROOT_CA_SUBJECT,
194+
DEFAULT_CA_VALIDITY,
195+
None,
196+
)
197+
}
198+
199+
#[test]
200+
fn customized_ca() {
201+
let ca = CertificateAuthorityBuilder::builder()
202+
.subject("CN=Test")
203+
.serial_number(42)
204+
.signing_key_pair(rsa::SigningKey::new().unwrap())
205+
.validity(Duration::from_days_unchecked(13))
206+
.build()
207+
.build_ca()
208+
.expect("failed to build CA");
209+
210+
assert_ca_cert_attributes(
211+
&ca.ca_cert().tbs_certificate,
212+
"CN=Test",
213+
Duration::from_days_unchecked(13),
214+
Some(42),
215+
)
216+
}
217+
218+
fn assert_ca_cert_attributes(
219+
ca_cert: &TbsCertificateInner,
220+
subject: &str,
221+
validity: Duration,
222+
serial_number: Option<u64>,
223+
) {
224+
assert_eq!(ca_cert.subject, subject.parse().unwrap());
225+
226+
let not_before = ca_cert.validity.not_before.to_system_time();
227+
let not_after = ca_cert.validity.not_after.to_system_time();
228+
assert_eq!(
229+
not_after
230+
.duration_since(not_before)
231+
.expect("Failed to calculate duration between notBefore and notAfter"),
232+
*validity
233+
);
234+
235+
if let Some(serial_number) = serial_number {
236+
assert_eq!(ca_cert.serial_number, SerialNumber::from(serial_number))
237+
} else {
238+
assert_ne!(ca_cert.serial_number, SerialNumber::from(0_u64))
239+
}
240+
}
241+
}
Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,13 @@
1+
use rsa::pkcs8::LineEnding;
12
use stackable_operator::time::Duration;
23

3-
/// The default CA validity time span of one hour (3600 seconds).
4+
/// The default CA validity time span of one hour.
45
pub const DEFAULT_CA_VALIDITY: Duration = Duration::from_hours_unchecked(1);
56

7+
/// The default certificate validity time span of one hour.
8+
pub const DEFAULT_CERTIFICATE_VALIDITY: Duration = Duration::from_hours_unchecked(1);
9+
610
/// The root CA subject name containing only the common name.
711
pub const SDP_ROOT_CA_SUBJECT: &str = "CN=Stackable Data Platform Internal CA";
12+
13+
pub const PEM_LINE_ENDING: LineEnding = LineEnding::LF;

0 commit comments

Comments
 (0)