Skip to content

feat!(stackable-certs): Use builder pattern and support SAN entries #1044

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

Open
wants to merge 21 commits into
base: main
Choose a base branch
from

Conversation

sbernauer
Copy link
Member

@sbernauer sbernauer commented May 21, 2025

Description

Needed for CRD conversions, as the apiserver needs to trust our cert

Current example code

let ca = CertificateAuthority::builder_with_ecdsa()
    .build()
    .expect("failed to build CA");

let certificate = CertificateBuilder::builder()
    .subject("CN=trino-coordinator-default-0")
    .signed_by(&ca)
    .build()
    .expect("failed to build certificate");

Definition of Done Checklist

  • Not all of these items are applicable to all PRs, the author should update this template to only leave the boxes in that are relevant
  • Please make sure all these things are done and tick the boxes
# Author
- [ ] Changes are OpenShift compatible
- [ ] CRD changes approved
- [ ] Integration tests passed (for non trivial changes)
# Reviewer
- [ ] Code contains useful comments
- [ ] (Integration-)Test cases added
- [ ] Documentation added or updated
- [ ] Changelog updated
- [ ] Cargo.toml only contains references to git tags (not specific commits or branches)
# Acceptance
- [ ] Feature Tracker has been updated
- [ ] Proper release label has been added

@sbernauer sbernauer self-assigned this May 21, 2025
@sbernauer sbernauer moved this to Development: Waiting for Review in Stackable Engineering May 21, 2025
@sbernauer sbernauer changed the title feat!(stackable-certs): Support adding SAN entries feat!(stackable-certs): Use builder pattern and support SAN entries May 23, 2025
Copy link
Member

@NickLarsenNZ NickLarsenNZ left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just commenting on a few things. Mostly thoughts.

Comment on lines 71 to 72
/// .build()
/// .build_ca()
Copy link
Member

@NickLarsenNZ NickLarsenNZ May 23, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This feels a little awkward to me. I'm not (yet) sure what the two build methods do differently?

It looks like the build() method still returns a builder, and build_ca() self-signs and returns a CertificateAuthority.

Point is, I don't find it intuitive and I think it should be.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I worked it out below, and recommended renaming the struct.

/// .expect("failed to build CA");
/// ```
#[derive(Builder)]
pub struct CertificateAuthorityBuilder<'a, SKP>
Copy link
Member

@NickLarsenNZ NickLarsenNZ May 23, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this should be:

Suggested change
pub struct CertificateAuthorityBuilder<'a, SKP>
pub struct CertificateAuthority<'a, SKP>

So when we call build(), we get back a CA and not a CA Builder.

Note, the Builder macro would otherwise generate CertificateAuthorityBuilderBuilder (ie: it generates the Builder, not us).

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To illustrate further:

  • CertificateAuthority::builder() -> CertificateAuthorityBuilder
  • CertificateAuthorityBuilder::build() -> a valid CertificateAuthority

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh, but we already have a struct: CertificateAuthority<SK> at

pub struct CertificateAuthority<SK>

in my mind, that one should be the one that derives Builder. Otherwise we need to revise what each struct is, because the current CertificateAuthorityBuilder is not a builder (per previous message above).

Comment on lines 209 to 210
let subject = Name::from_str(SDP_ROOT_CA_SUBJECT).context(ParseSubjectSnafu {
subject: SDP_ROOT_CA_SUBJECT,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

note: While we are at it, I think this should use expect instead. This subject name must always pass.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think I tend to disagree here. My argumentation would be that stackable-certs doesn't know where the subject is coming from. It could be CN=<pod-name>, but maybe also a certificate we issue for users (user-provided subject?), so they can connect to e.g. mTLS Kafka.
Better safe than sorry I'd say

Comment on lines 71 to 75
#[snafu(display("The subject alternative DNS name \"{dns_name}\" is not a Ia5String"))]
SaDnsNameNotAIa5String {
dns_name: String,
source: x509_cert::der::Error,
},
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: Rename the variant and adjust error message.

Suggested change
#[snafu(display("The subject alternative DNS name \"{dns_name}\" is not a Ia5String"))]
SaDnsNameNotAIa5String {
dns_name: String,
source: x509_cert::der::Error,
},
#[snafu(display("failed to parse subject alternative name {subject_alternative_name:?} as a Ia5 string"))]
ParseSubjectAlternativeName {
subject_alternative_name: String,
source: x509_cert::der::Error,
},

Copy link
Member Author

@sbernauer sbernauer May 26, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changed to

    #[snafu(display(
        "failed to parse subject alternative DNS name \"{subject_alternative_dns_name}\" as a Ia5 string"
    ))]
    ParseSubjectAlternativeDnsName {
        subject_alternative_dns_name: String,
        source: x509_cert::der::Error,
    },

in 8f86a93

Comment on lines 91 to 94
/// Serial number of the generated certificate.
///
/// If not specified a random serial will be generated.
serial_number: Option<u64>,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

note: This is an internal detail we shouldn't allow being customized by the user of this library. I'm sure there is something like

Suggested change
/// Serial number of the generated certificate.
///
/// If not specified a random serial will be generated.
serial_number: Option<u64>,
/// Serial number of the generated certificate.
#[builder(skip)]
serial_number: Option<u64>,

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed. On that topic, if we want to have proper CAs... then serial should increment.
So, if we restore a CA (eg: on startup, read from a secret to get the CA keypair), we also need information for the certs that have been issued (not by reading available signed certs, since they might be gone already). We basically need an atomic counter.

I'm not 100% certain of the downside if we just always issue the same version though.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I copied the builder to mimic the current api, in which users can specify the serial:

pub fn new_with(signing_key_pair: S, serial_number: u64, validity: Duration) -> Result<Self> {

secret-op generates random serials for cas and certs (https://github.com/stackabletech/secret-operator/blob/00909cfb92e61539a4baab9190235cd33b33a61b/rust/operator-binary/src/backend/tls/ca.rs#L258 and https://github.com/stackabletech/secret-operator/blob/00909cfb92e61539a4baab9190235cd33b33a61b/rust/operator-binary/src/backend/tls/mod.rs#L306), this seems to be fine.
I will update the PR to not let users choose the serial

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment on lines +96 to +99
/// Cryptographic keypair used to sign leaf certificates.
///
/// If not specified a random keypair will be generated.
signing_key_pair: Option<SKP>,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

note: I would like to see the same high-level functions to create a RSA or ECDSA based CA.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added in fd19a03

Comment on lines -167 to -180
/// Creates a new CA certificate with many parameters set to their default
/// values.
///
/// These parameters include:
///
/// - a randomly generated serial number
/// - a default validity of one hour (see [`DEFAULT_CA_VALIDITY_SECONDS`])
///
/// The CA contains the public half of the provided `signing_key` and is
/// signed by the private half of said key.
///
/// If the default values for the serial number and validity don't satisfy
/// the requirements of the caller, use [`CertificateAuthority::new_with`]
/// instead.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

note: Compared to these docs, the current docs lack in depth and technical explanations. I've spent a significant amount of time on these and would like to see them carried over to the new implementation as complete as possible.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tried carrying over everything that is still relevant: 1322779

Comment on lines 108 to 111
/// Serial number of the generated certificate.
///
/// If not specified a random serial will be generated.
serial_number: Option<u64>,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

note: Again, I would like this to be an internal details not exposed to the user.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as #1044 (comment)

Comment on lines +118 to +119
/// Mandatorily sign the certificate using the provided [`CertificateAuthority`].
signed_by: &'a CertificateAuthority<KP>,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

note: This should be part of the .build() method on the CertificateBuilder.

Comment on lines 142 to 154
let ca_validity = self.signed_by.ca_cert().tbs_certificate.validity;
let ca_not_after = ca_validity.not_after.to_system_time();
let cert_not_after = validity.not_after.to_system_time();
if ca_not_after < cert_not_after {
warn!(
ca.validity = ?ca_validity,
cert.validity = ?validity,
ca.not_after = ?ca_not_after,
cert.not_after = ?cert_not_after,
subject = ?subject,
"The lifetime of certificate authority is shorted than the lifetime of the generated certificate",
);
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

praise: Thank you for adding this.

note: This should be a hard error instead.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment on lines 97 to 100
/// Optional list of subject alternative names (SAN) DNS entries,
/// that are added to the certificate.
#[builder(default)]
subject_alterative_dns_names: &'a [&'a str],
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: I would just go with

Suggested change
/// Optional list of subject alternative names (SAN) DNS entries,
/// that are added to the certificate.
#[builder(default)]
subject_alterative_dns_names: &'a [&'a str],
/// Optional list of subject alternative names (SAN) DNS entries,
/// that are added to the certificate.
#[builder(default)]
subject_alterative_names: &'a [&'a str],

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, there is no value in carrying this as &str. They need to be converted into owned values below anyway. The API should reflect that to not hide (possibly) unexpected cloning/allocation.

Copy link
Member

@NickLarsenNZ NickLarsenNZ May 23, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we want to allow for more things, like IP's, email addresses, etc... (just so we avoid premature restrictions)?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Secret-op already does IP SANs, so yes.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh I misread, I though @nightkr requested to support IPs... I added support for that in 8f86a93 🙈

But as this is a builder I prefer to have a function to add DNs names and one to add IP addresses instead of passing a list of enums (which we first need to introduce).

Regarding the allocation, I see the point that this allocates. On the other hand I prefer the simple api, so that not every caller needs to deal with der::asn1::ia5_string::allocation::Ia5String

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh I misread, I though @nightkr requested to support IPs... I added support for that in 8f86a93 🙈

I was. (At least from the perspective of "if we want to use it for secret-op at some point".)

Comment on lines 191 to 197
.map(|dns_name| {
Ok(GeneralName::DnsName(Ia5String::new(dns_name).context(
SaDnsNameNotAIa5StringSnafu {
dns_name: dns_name.to_string(),
},
)?))
})
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

note: Please flatten this a little bit. I'm not a huge fan of these nested code snippets.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The best I could come up was changing

        let san_dns = self.subject_alterative_dns_names.iter().map(|dns_name| {
            Ok(GeneralName::DnsName(
                Ia5String::new(dns_name).with_context(|_| ParseSubjectAlternativeDnsNameSnafu {
                    subject_alternative_dns_name: dns_name.to_string(),
                })?,
            ))
        });

to

        let san_dns = self.subject_alterative_dns_names.iter().map(
            |dns_name| -> Result<_, CreateCertificateError<KP::Error>> {
                Ia5String::new(dns_name)
                    .with_context(|_| ParseSubjectAlternativeDnsNameSnafu {
                        subject_alternative_dns_name: dns_name.to_string(),
                    })
                    .map(GeneralName::DnsName)
            },
        );

Not sure if that really improves the situation

@sbernauer sbernauer moved this from Development: Waiting for Review to Development: In Review in Stackable Engineering May 26, 2025
@sbernauer sbernauer requested a review from Techassi May 27, 2025 12:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
Status: Development: In Review
Development

Successfully merging this pull request may close these issues.

4 participants