From 5419745104addb7a6cd44ef2d2b0e7c959d7d717 Mon Sep 17 00:00:00 2001 From: Jacob Hoffman-Andrews Date: Thu, 23 Jul 2026 19:23:41 -0700 Subject: [PATCH 1/3] unsigned: implement RFC 9925 unsigned.Design removes the signature from an x509.Certificate and optionally replaces the signature algorithm with id-alg-unsigned (which makes it a true RFC 9925 unsigned certificate). This is useful for (a) generating MTC CA certificates and (b) reducing the size of lint certs that we store in the database. We don't want to replace the signature algorithm when storing lint certs, because we will later verify that all fields of a CA-signed precertificate match all fields of a lint final certificate, and swapping out signature algorithm would cause that check to break. These certificates can be parsed with `x509.ParseCertificate`, but of course they can't be verified. --- linter/linter.go | 5 +- test/certs/genmtpki/genmtpki.go | 11 +++- unsigned/unsigned.go | 109 ++++++++++++++++++++++++++++++++ unsigned/unsigned_test.go | 79 +++++++++++++++++++++++ 4 files changed, 200 insertions(+), 4 deletions(-) create mode 100644 unsigned/unsigned.go create mode 100644 unsigned/unsigned_test.go diff --git a/linter/linter.go b/linter/linter.go index e52aa6a3e81..20172175108 100644 --- a/linter/linter.go +++ b/linter/linter.go @@ -13,6 +13,7 @@ import ( "github.com/zmap/zlint/v3/lint" "github.com/letsencrypt/boulder/core" + "github.com/letsencrypt/boulder/unsigned" _ "github.com/letsencrypt/boulder/linter/lints/cabf_br" _ "github.com/letsencrypt/boulder/linter/lints/chrome" @@ -46,7 +47,7 @@ func Check(tbs *x509.Certificate, subjectPubKey crypto.PublicKey, realIssuer *x5 return nil, err } - return lintCertBytes, nil + return unsigned.Design(lintCertBytes, false) } // CheckCRL is like Check, but for CRLs. @@ -119,7 +120,7 @@ func (l *Linter) Check(tbs *x509.Certificate, subjectPubKey crypto.PublicKey, re return nil, err } - return lintCertBytes, nil + return unsigned.Design(lintCertBytes, false) } // CheckCRL signs the given RevocationList template using the Linter's fake diff --git a/test/certs/genmtpki/genmtpki.go b/test/certs/genmtpki/genmtpki.go index e71f6d3b7d3..36e0c314eb1 100644 --- a/test/certs/genmtpki/genmtpki.go +++ b/test/certs/genmtpki/genmtpki.go @@ -16,6 +16,8 @@ import ( "os" "path" "time" + + "github.com/letsencrypt/boulder/unsigned" ) func main() { @@ -85,7 +87,12 @@ func main2() error { return err } - _, err = x509.ParseCertificate(certBytes) + unsigned, err := unsigned.Design(certBytes, true) + if err != nil { + return err + } + + _, err = x509.ParseCertificate(unsigned) if err != nil { return err } @@ -96,7 +103,7 @@ func main2() error { } defer certFile.Close() - err = pem.Encode(certFile, &pem.Block{Type: "CERTIFICATE", Bytes: certBytes}) + err = pem.Encode(certFile, &pem.Block{Type: "CERTIFICATE", Bytes: unsigned}) if err != nil { return err } diff --git a/unsigned/unsigned.go b/unsigned/unsigned.go new file mode 100644 index 00000000000..314bc3487e5 --- /dev/null +++ b/unsigned/unsigned.go @@ -0,0 +1,109 @@ +// Package unsigned implements RFC 9925 Unsigned X.509 Certificates. +// +// https://datatracker.ietf.org/doc/html/rfc9925 +package unsigned + +import ( + encoding_asn1 "encoding/asn1" + "fmt" + + "golang.org/x/crypto/cryptobyte" + "golang.org/x/crypto/cryptobyte/asn1" +) + +// Design parses the input as an X.509 certificate and returns an unsigned certificate +// with the same contents. +// +// RFC 9925 says "Senders SHOULD omit the authority key identifier and issuer alternative +// name extensions", but since we use this for returning lint certificate bytes we leave +// all fields unchanged, including those fields. +// +// If replaceSigAlg is true, Design emits a proper RFC 9925 certificate with the signature +// algorithm id-alg-unsigned in both slots. If false, it retains the original signature +// algorithm and simply truncates the signature. This is convenient for efficiently storing +// lint certificates where we don't care about the signature bytes. +func Design(cert []byte, replaceSigAlg bool) ([]byte, error) { + certificate := cryptobyte.String(cert) + + // https://datatracker.ietf.org/doc/html/rfc5280#page-116 + // + // Certificate ::= SEQUENCE { + // tbsCertificate TBSCertificate, + // signatureAlgorithm AlgorithmIdentifier, + // signature BIT STRING } + // + // TBSCertificate ::= SEQUENCE { + // version [0] Version DEFAULT v1, + // serialNumber CertificateSerialNumber, + // signature AlgorithmIdentifier, + // ... + var certificateInner cryptobyte.String + if !certificate.ReadASN1(&certificateInner, asn1.SEQUENCE) { + return nil, fmt.Errorf("failed to read outer sequence") + } + + var tbsCertificate cryptobyte.String + if !certificateInner.ReadASN1(&tbsCertificate, asn1.SEQUENCE) { + return nil, fmt.Errorf("failed to read tbsCertificate") + } + + var versionElement cryptobyte.String + if !tbsCertificate.ReadASN1Element(&versionElement, asn1.Tag(0).Constructed().ContextSpecific()) { + return nil, fmt.Errorf("failed to read version: %x", tbsCertificate) + } + + var serialNumberElement cryptobyte.String + if !tbsCertificate.ReadASN1Element(&serialNumberElement, asn1.INTEGER) { + return nil, fmt.Errorf("failed to read serial number") + } + + var innerSignatureAlgorithm cryptobyte.String + if !tbsCertificate.ReadASN1(&innerSignatureAlgorithm, asn1.SEQUENCE) { + return nil, fmt.Errorf("failed to read inner signature field") + } + + // back out to the certificate + var signatureAlgorithm cryptobyte.String + if !certificateInner.ReadASN1Element(&signatureAlgorithm, asn1.SEQUENCE) { + return nil, fmt.Errorf("failed to read signatureAlgorithm: %x", certificate) + } + + var signature cryptobyte.String + if !certificateInner.ReadASN1(&signature, asn1.BIT_STRING) { + return nil, fmt.Errorf("failed to read signature") + } + + if !certificate.Empty() { + return nil, fmt.Errorf("extra bytes at end") + } + + var b cryptobyte.Builder + + idAlgUnsigned := encoding_asn1.ObjectIdentifier{1, 3, 6, 1, 5, 5, 7, 6, 36} + + b.AddASN1(asn1.SEQUENCE, func(outTBS *cryptobyte.Builder) { + outTBS.AddASN1(asn1.SEQUENCE, func(outBytes *cryptobyte.Builder) { + outBytes.AddBytes(versionElement) + outBytes.AddBytes(serialNumberElement) + outBytes.AddASN1(asn1.SEQUENCE, func(algorithmIdentifier *cryptobyte.Builder) { + if replaceSigAlg { + algorithmIdentifier.AddASN1ObjectIdentifier(idAlgUnsigned) + } else { + algorithmIdentifier.AddBytes(innerSignatureAlgorithm) + } + }) + outBytes.AddBytes([]byte(tbsCertificate)) + }) + outTBS.AddASN1(asn1.SEQUENCE, func(algorithmIdentifier *cryptobyte.Builder) { + if replaceSigAlg { + algorithmIdentifier.AddASN1ObjectIdentifier(idAlgUnsigned) + } else { + algorithmIdentifier.AddBytes(innerSignatureAlgorithm) + } + }) + // The Certificate's signatureValue field MUST be a BIT STRING of length zero. + outTBS.AddASN1BitString(nil) + }) + + return b.Bytes() +} diff --git a/unsigned/unsigned_test.go b/unsigned/unsigned_test.go new file mode 100644 index 00000000000..5275ddc1663 --- /dev/null +++ b/unsigned/unsigned_test.go @@ -0,0 +1,79 @@ +package unsigned + +import ( + "bytes" + "crypto/x509" + "encoding/base64" + "strings" + "testing" +) + +func TestDesign(t *testing.T) { + input, err := base64.StdEncoding.DecodeString(strings.ReplaceAll(` +MIIB4TCCAWegAwIBAgIIJuDkMteShO8wCgYIKoZIzj0EAwMwIDEeMBwGA1UEAxMV +bWluaWNhIHJvb3QgY2EgNGU0YjFkMB4XDTI2MDYyOTA1NDUyNloXDTI4MDcyOTA1 +NDUyNlowFjEUMBIGA1UEAxMLd2ZlLmJvdWxkZXIwdjAQBgcqhkjOPQIBBgUrgQQA +IgNiAATLkY1wBrGl9+jhR4+HSycRv5kvVV8LUO3xY1styu8+q9kaSi03wrdH7LUf +rJkRE6S60XzVXkeqL9N//jOXFsaM9JbsbeHFRoAx+mBEV68Vu69dblxtXIAKNlMM +5dav5XOjeDB2MA4GA1UdDwEB/wQEAwIFoDAdBgNVHSUEFjAUBggrBgEFBQcDAQYI +KwYBBQUHAwIwDAYDVR0TAQH/BAIwADAfBgNVHSMEGDAWgBSA+ZfinkHdxJDZuRo1 +zJ7mHOmaCDAWBgNVHREEDzANggt3ZmUuYm91bGRlcjAKBggqhkjOPQQDAwNoADBl +AjEApE8cwaAQ6hnGtUM/TWAb54E5/29ZVy5E/UY8mEzoE021pl3tq1fEof5qz5n/ +KrL4AjAuEpVOjRrRWWMnRJxd05Pfxq7gZmxgwppjnE9JZ9P6WRP7ZWqZcc9p8YLM +YhKuXQo=`, "\n", "")) + if err != nil { + t.Fatal(err) + } + + out, err := Design(input, true) + if err != nil { + t.Fatal(err) + } + expected, err := base64.StdEncoding.DecodeString(strings.ReplaceAll(` +MIIBejCCAWegAwIBAgIIJuDkMteShO8wCgYIKwYBBQUHBiQwIDEeMBwGA1UEAxMV +bWluaWNhIHJvb3QgY2EgNGU0YjFkMB4XDTI2MDYyOTA1NDUyNloXDTI4MDcyOTA1 +NDUyNlowFjEUMBIGA1UEAxMLd2ZlLmJvdWxkZXIwdjAQBgcqhkjOPQIBBgUrgQQA +IgNiAATLkY1wBrGl9+jhR4+HSycRv5kvVV8LUO3xY1styu8+q9kaSi03wrdH7LUf +rJkRE6S60XzVXkeqL9N//jOXFsaM9JbsbeHFRoAx+mBEV68Vu69dblxtXIAKNlMM +5dav5XOjeDB2MA4GA1UdDwEB/wQEAwIFoDAdBgNVHSUEFjAUBggrBgEFBQcDAQYI +KwYBBQUHAwIwDAYDVR0TAQH/BAIwADAfBgNVHSMEGDAWgBSA+ZfinkHdxJDZuRo1 +zJ7mHOmaCDAWBgNVHREEDzANggt3ZmUuYm91bGRlcjAKBggrBgEFBQcGJAMBAA== +`, "\n", "")) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(out, expected) { + t.Errorf("got %x, want %x", out, expected) + } + + _, err = x509.ParseCertificate(out) + if err != nil { + t.Fatal(err) + } + + out, err = Design(input, false) + if err != nil { + t.Fatal(err) + } + expected, err = base64.StdEncoding.DecodeString(strings.ReplaceAll(` +MIIBejCCAWegAwIBAgIIJuDkMteShO8wCgYIKoZIzj0EAwMwIDEeMBwGA1UEAxMV +bWluaWNhIHJvb3QgY2EgNGU0YjFkMB4XDTI2MDYyOTA1NDUyNloXDTI4MDcyOTA1 +NDUyNlowFjEUMBIGA1UEAxMLd2ZlLmJvdWxkZXIwdjAQBgcqhkjOPQIBBgUrgQQA +IgNiAATLkY1wBrGl9+jhR4+HSycRv5kvVV8LUO3xY1styu8+q9kaSi03wrdH7LUf +rJkRE6S60XzVXkeqL9N//jOXFsaM9JbsbeHFRoAx+mBEV68Vu69dblxtXIAKNlMM +5dav5XOjeDB2MA4GA1UdDwEB/wQEAwIFoDAdBgNVHSUEFjAUBggrBgEFBQcDAQYI +KwYBBQUHAwIwDAYDVR0TAQH/BAIwADAfBgNVHSMEGDAWgBSA+ZfinkHdxJDZuRo1 +zJ7mHOmaCDAWBgNVHREEDzANggt3ZmUuYm91bGRlcjAKBggqhkjOPQQDAwMBAA== +`, "\n", "")) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(out, expected) { + t.Errorf("got %x, want %x", out, expected) + } + + _, err = x509.ParseCertificate(out) + if err != nil { + t.Fatal(err) + } +} From ab5ffd65c1c3d740bc619a72a5c75f8ff4fbeec8 Mon Sep 17 00:00:00 2001 From: Aaron Gable Date: Thu, 30 Jul 2026 11:11:12 -0700 Subject: [PATCH 2/3] Review comments --- features/features.go | 6 ++++++ linter/linter.go | 11 +++++++++-- test/certs/genmtpki/genmtpki.go | 6 +++--- test/config-next/ca.json | 4 +++- unsigned/unsigned.go | 4 ++-- 5 files changed, 23 insertions(+), 8 deletions(-) diff --git a/features/features.go b/features/features.go index 702a41a6570..2e5ad31b5eb 100644 --- a/features/features.go +++ b/features/features.go @@ -73,6 +73,12 @@ type Config struct { // to find and revoke accounts using keys which have been added to the // blockedKeys table. RevokeBadKeyAccounts bool + + // UnsignLintCerts controls whether the linting package returns RFC 9925 + // Unsigned versions of the linting precerts it checks. This in turn controls + // the the contents of the precertificates table (which actually stores + // linting precerts), saving on storage volume by dropping fake signatures. + UnsignLintCerts bool } var fMu = new(sync.RWMutex) diff --git a/linter/linter.go b/linter/linter.go index 20172175108..10ab5236c3c 100644 --- a/linter/linter.go +++ b/linter/linter.go @@ -13,6 +13,7 @@ import ( "github.com/zmap/zlint/v3/lint" "github.com/letsencrypt/boulder/core" + "github.com/letsencrypt/boulder/features" "github.com/letsencrypt/boulder/unsigned" _ "github.com/letsencrypt/boulder/linter/lints/cabf_br" @@ -47,7 +48,10 @@ func Check(tbs *x509.Certificate, subjectPubKey crypto.PublicKey, realIssuer *x5 return nil, err } - return unsigned.Design(lintCertBytes, false) + if features.Get().UnsignLintCerts { + return unsigned.Design(lintCertBytes, false) + } + return lintCertBytes, nil } // CheckCRL is like Check, but for CRLs. @@ -120,7 +124,10 @@ func (l *Linter) Check(tbs *x509.Certificate, subjectPubKey crypto.PublicKey, re return nil, err } - return unsigned.Design(lintCertBytes, false) + if features.Get().UnsignLintCerts { + return unsigned.Design(lintCertBytes, false) + } + return lintCertBytes, nil } // CheckCRL signs the given RevocationList template using the Linter's fake diff --git a/test/certs/genmtpki/genmtpki.go b/test/certs/genmtpki/genmtpki.go index 36e0c314eb1..4b7380d1a9a 100644 --- a/test/certs/genmtpki/genmtpki.go +++ b/test/certs/genmtpki/genmtpki.go @@ -87,12 +87,12 @@ func main2() error { return err } - unsigned, err := unsigned.Design(certBytes, true) + unsignedBytes, err := unsigned.Design(certBytes, true) if err != nil { return err } - _, err = x509.ParseCertificate(unsigned) + _, err = x509.ParseCertificate(unsignedBytes) if err != nil { return err } @@ -103,7 +103,7 @@ func main2() error { } defer certFile.Close() - err = pem.Encode(certFile, &pem.Block{Type: "CERTIFICATE", Bytes: unsigned}) + err = pem.Encode(certFile, &pem.Block{Type: "CERTIFICATE", Bytes: unsignedBytes}) if err != nil { return err } diff --git a/test/config-next/ca.json b/test/config-next/ca.json index f31478380ec..ce79bf7acb1 100644 --- a/test/config-next/ca.json +++ b/test/config-next/ca.json @@ -180,7 +180,9 @@ "goodkey": {}, "ocspLogMaxLength": 4000, "ctLogListFile": "test/ct-test-srv/log_list.json", - "features": {} + "features": { + "UnsignLintCerts": true + } }, "pa": { "challenges": { diff --git a/unsigned/unsigned.go b/unsigned/unsigned.go index 314bc3487e5..bbe3d19c7d3 100644 --- a/unsigned/unsigned.go +++ b/unsigned/unsigned.go @@ -49,7 +49,7 @@ func Design(cert []byte, replaceSigAlg bool) ([]byte, error) { var versionElement cryptobyte.String if !tbsCertificate.ReadASN1Element(&versionElement, asn1.Tag(0).Constructed().ContextSpecific()) { - return nil, fmt.Errorf("failed to read version: %x", tbsCertificate) + return nil, fmt.Errorf("failed to read version") } var serialNumberElement cryptobyte.String @@ -65,7 +65,7 @@ func Design(cert []byte, replaceSigAlg bool) ([]byte, error) { // back out to the certificate var signatureAlgorithm cryptobyte.String if !certificateInner.ReadASN1Element(&signatureAlgorithm, asn1.SEQUENCE) { - return nil, fmt.Errorf("failed to read signatureAlgorithm: %x", certificate) + return nil, fmt.Errorf("failed to read signatureAlgorithm") } var signature cryptobyte.String From edc7b564d55082d80a30a6ef5496d5fdb8036bf5 Mon Sep 17 00:00:00 2001 From: Aaron Gable Date: Thu, 30 Jul 2026 14:56:10 -0700 Subject: [PATCH 3/3] Fix whitespace from bad merge --- features/features.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/features/features.go b/features/features.go index feb4dd1c207..d1e254acf19 100644 --- a/features/features.go +++ b/features/features.go @@ -79,7 +79,7 @@ type Config struct { // unnecessary work due to parallel validations, but requires a database // change to work. SetAuthzProcessing bool - + // UnsignLintCerts controls whether the linting package returns RFC 9925 // Unsigned versions of the linting precerts it checks. This in turn controls // the the contents of the precertificates table (which actually stores