diff --git a/features/features.go b/features/features.go index d57e31a4454..d1e254acf19 100644 --- a/features/features.go +++ b/features/features.go @@ -79,6 +79,12 @@ 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 + // 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 48f9258715b..64bfb50dbd3 100644 --- a/linter/linter.go +++ b/linter/linter.go @@ -13,6 +13,8 @@ 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" _ "github.com/letsencrypt/boulder/linter/lints/chrome" @@ -51,6 +53,9 @@ func Check(tbs *x509.Certificate, subjectPubKey crypto.PublicKey, realIssuer *x5 return nil, err } + if features.Get().UnsignLintCerts { + return unsigned.Design(lintCertBytes, false) + } return lintCertBytes, nil } @@ -135,6 +140,9 @@ func (l *Linter) Check(tbs *x509.Certificate, subjectPubKey crypto.PublicKey, re return nil, err } + if features.Get().UnsignLintCerts { + return unsigned.Design(lintCertBytes, false) + } return lintCertBytes, nil } diff --git a/test/certs/genmtpki/genmtpki.go b/test/certs/genmtpki/genmtpki.go index e71f6d3b7d3..4b7380d1a9a 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) + unsignedBytes, err := unsigned.Design(certBytes, true) + if err != nil { + return err + } + + _, err = x509.ParseCertificate(unsignedBytes) 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: unsignedBytes}) if err != nil { return err } diff --git a/test/config-next/ca.json b/test/config-next/ca.json index 574685b36bf..4da909d597c 100644 --- a/test/config-next/ca.json +++ b/test/config-next/ca.json @@ -177,7 +177,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 new file mode 100644 index 00000000000..bbe3d19c7d3 --- /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") + } + + 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") + } + + 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) + } +}