diff --git a/trees/entry/entry.go b/trees/entry/entry.go index 6f5beaf1887..89c8a109819 100644 --- a/trees/entry/entry.go +++ b/trees/entry/entry.go @@ -6,8 +6,7 @@ // // Entry bundles contain MTCLogEntry. MTCLogEntry contains (typically) TBSCertificateLogEntry. // -// TBSCertificateLogEntry is part of the X.509 layer. It will be used to build certificates -// (TODO: implement TBSCertificateLogEntry.ToX509). +// TBSCertificateLogEntry is part of the X.509 layer. It will be used to build certificates. // // MTCLogEntry is part of the Merkle tree layer and is what gets hashed. It provides type switching // (needed for null_entry) and extensibility. @@ -28,6 +27,8 @@ import ( "golang.org/x/crypto/cryptobyte" "golang.org/x/crypto/cryptobyte/asn1" + + "github.com/letsencrypt/boulder/trees/proof" ) const typeNullEntry = 0 @@ -55,6 +56,9 @@ type MTCLogEntry struct { // TBS returns the TBSCertificateLogEntry bytes if Type is tbs_cert_entry, or nil otherwise. func (mtcle *MTCLogEntry) TBS() []byte { + if mtcle == nil { + return nil + } if mtcle.typ == typeTBSCertEntry { return mtcle.value } @@ -227,7 +231,7 @@ func FromX509(in []byte, hash crypto.Hash) (*MTCLogEntry, error) { return nil, fmt.Errorf("malformed algorithmIdentifier") } - // Read the extensions. + // Read the extensions. We consider them required in practice. // // Note that we've ignored issuerUniqueID and subjectUniqueID, which are OPTIONAL and // forbidden by the BRs. Since those fields have encoding instructions ([1] and [2]), @@ -353,3 +357,161 @@ func (br *BundleReader) ReadEntry() (*MTCLogEntry, []byte, error) { return mtcle, body, nil } + +// ToTBSCertificate generates a TBSCertificate corresponding to a TBSCertificateLogEntry. +// +// `serial` must be the entry index combined with log number: (log_number << 48) | index. +// `subjectPublicKeyInfo` must be a DER-encoded subjectPublicKeyInfo (including tag and length). +// `hash` must be the CA's hash function. +// +// If the MTCLogEntry does not contain a TBSCertificateLogEntry, error. +// +// Checks `subjectPublicKeyInfo` against `subjectPublicKeyInfoHash` and `subjectPublicKeyAlgorithm` +// from the TBSCertificateLogEntry. +func (mtcle *MTCLogEntry) ToTBSCertificate( + serial uint64, + subjectPublicKeyInfo []byte, + hash crypto.Hash, +) ([]byte, error) { + if mtcle == nil || mtcle.typ != typeTBSCertEntry { + return nil, fmt.Errorf("MTCLogEntry type was not tbs_cert_entry") + } + + if serial == 0 { + return nil, fmt.Errorf("serial number was zero") + } + + var spkiInner cryptobyte.String + spkiString := cryptobyte.String(subjectPublicKeyInfo) + if !spkiString.ReadASN1(&spkiInner, asn1.SEQUENCE) { + return nil, fmt.Errorf("malformed SPKI") + } + + var subjectPublicKeyAlgorithmFromSPKI cryptobyte.String + if !spkiInner.ReadASN1(&subjectPublicKeyAlgorithmFromSPKI, asn1.SEQUENCE) { + return nil, fmt.Errorf("malformed SPKI") + } + + tbsCertificateLogEntry := cryptobyte.String(mtcle.TBS()) + + // TBSCertificateLogEntry ::= SEQUENCE { + // version [0] EXPLICIT Version DEFAULT v1, + // issuer Name, + // validity Validity, + // subject Name, + // subjectPublicKeyAlgorithm AlgorithmIdentifier{PUBLIC-KEY, + // {PublicKeyAlgorithms}}, + // subjectPublicKeyInfoHash OCTET STRING, + // issuerUniqueID [1] IMPLICIT UniqueIdentifier OPTIONAL, + // subjectUniqueID [2] IMPLICIT UniqueIdentifier OPTIONAL, + // extensions [3] EXPLICIT Extensions{{CertExtensions}} + // OPTIONAL + // } + var version cryptobyte.String + if !tbsCertificateLogEntry.ReadASN1(&version, asn1.Tag(0).Constructed().ContextSpecific()) { + return nil, fmt.Errorf("failed to read version") + } + // Version should always be v3, which is represented as an ASN.1 INTEGER 2. + // That's tag 2, length 1, value 2. + if !bytes.Equal(version, []byte{2, 1, 2}) { + return nil, fmt.Errorf("invalid X.509 version") + } + var fields []cryptobyte.String + // issuer, validity, subject + for range 3 { + var fieldElement cryptobyte.String + var fieldTag asn1.Tag + + if !tbsCertificateLogEntry.ReadAnyASN1Element(&fieldElement, &fieldTag) { + return nil, fmt.Errorf("failed to read field") + } + + fields = append(fields, fieldElement) + } + + // Read and validate subjectPublicKeyAlgorithmFromTBS and subjectPublicKeyInfoHash. + var subjectPublicKeyAlgorithmFromTBS cryptobyte.String + if !tbsCertificateLogEntry.ReadASN1(&subjectPublicKeyAlgorithmFromTBS, asn1.SEQUENCE) { + return nil, fmt.Errorf("malformed subjectPublicKeyAlgorithm") + } + + if !bytes.Equal(subjectPublicKeyAlgorithmFromSPKI, subjectPublicKeyAlgorithmFromTBS) { + return nil, fmt.Errorf("mismatched subjectPublicKeyAlgorithm in TBS (%x) and in SPKI (%x)", + subjectPublicKeyAlgorithmFromTBS, subjectPublicKeyAlgorithmFromSPKI) + } + + var subjectPublicKeyInfoHash cryptobyte.String + if !tbsCertificateLogEntry.ReadASN1(&subjectPublicKeyInfoHash, asn1.OCTET_STRING) { + return nil, fmt.Errorf("malformed subjectPublicKeyInfoHash") + } + + h := hash.New() + h.Write(subjectPublicKeyInfo) + spkiHash := h.Sum(nil) + + if !bytes.Equal([]byte(subjectPublicKeyInfoHash), spkiHash) { + return nil, fmt.Errorf("mismatched subjectPublicKeyInfoHash: %x vs %x", + subjectPublicKeyInfoHash, spkiHash) + } + + // Read the extensions. We consider them required in practice. + // + // Note that we've ignored issuerUniqueID and subjectUniqueID, which are OPTIONAL and + // forbidden by the BRs. Since those fields have encoding instructions ([1] and [2]), + // if by some chance they are present we will error when trying to read extensions, + // which has an encoding instruction of [3]. + var extensions cryptobyte.String + extensionsTag := asn1.Tag(3).Constructed().ContextSpecific() + if !tbsCertificateLogEntry.ReadASN1Element(&extensions, extensionsTag) { + return nil, fmt.Errorf("error reading extensions") + } + + if !tbsCertificateLogEntry.Empty() { + return nil, fmt.Errorf("extra bytes at end") + } + + // https://datatracker.ietf.org/doc/html/rfc5280#page-117 + // TBSCertificate ::= SEQUENCE { + // version [0] Version DEFAULT v1, + // serialNumber CertificateSerialNumber, + // signature AlgorithmIdentifier, + // issuer Name, + // validity Validity, + // subject Name, + // subjectPublicKeyInfo SubjectPublicKeyInfo, + // issuerUniqueID [1] IMPLICIT UniqueIdentifier OPTIONAL, + // -- If present, version MUST be v2 or v3 + // subjectUniqueID [2] IMPLICIT UniqueIdentifier OPTIONAL, + // -- If present, version MUST be v2 or v3 + // extensions [3] Extensions OPTIONAL + // -- If present, version MUST be v3 -- } + var builder cryptobyte.Builder + + builder.AddASN1(asn1.SEQUENCE, func(tbsCertificate *cryptobyte.Builder) { + // version + tbsCertificate.AddASN1(asn1.Tag(0).Constructed().ContextSpecific(), func(child *cryptobyte.Builder) { + child.AddASN1Int64(2) + }) + + // serialNumber + tbsCertificate.AddASN1Uint64(serial) + + // signature + tbsCertificate.AddBytes(proof.SigAlgEncoded()) + + // issuer, validity, subject + for _, f := range fields { + // The fields were read with ReadASN1Element so they still include + // their tag and length. Add them straight to the builder. + tbsCertificate.AddBytes(f) + } + + // subjectPublicKeyInfo + tbsCertificate.AddBytes(subjectPublicKeyInfo) + + // extensions + tbsCertificate.AddBytes(extensions) + }) + + return builder.Bytes() +} diff --git a/trees/entry/entry_test.go b/trees/entry/entry_test.go index b79f99d671c..a5d65edcb91 100644 --- a/trees/entry/entry_test.go +++ b/trees/entry/entry_test.go @@ -3,17 +3,25 @@ package entry import ( "bytes" "crypto" + "crypto/x509" "encoding/base64" "encoding/hex" "errors" "io" + "math/big" + "reflect" "strings" "testing" + + "golang.org/x/crypto/cryptobyte" + "golang.org/x/crypto/cryptobyte/asn1" + + "github.com/letsencrypt/boulder/trees/proof" ) -func TestFromX509(t *testing.T) { - // Bytes from an example generated test/certs/ipki/wfe.boulder/cert.pem - input, err := base64.StdEncoding.DecodeString(strings.ReplaceAll(` +// testCertB64 contains an example certificate generated from +// test/certs/ipki/wfe.boulder/cert.pem. +const testCertB64 = ` MIIB4TCCAWegAwIBAgIIJuDkMteShO8wCgYIKoZIzj0EAwMwIDEeMBwGA1UEAxMV bWluaWNhIHJvb3QgY2EgNGU0YjFkMB4XDTI2MDYyOTA1NDUyNloXDTI4MDcyOTA1 NDUyNlowFjEUMBIGA1UEAxMLd2ZlLmJvdWxkZXIwdjAQBgcqhkjOPQIBBgUrgQQA @@ -24,10 +32,26 @@ KwYBBQUHAwIwDAYDVR0TAQH/BAIwADAfBgNVHSMEGDAWgBSA+ZfinkHdxJDZuRo1 zJ7mHOmaCDAWBgNVHREEDzANggt3ZmUuYm91bGRlcjAKBggqhkjOPQQDAwNoADBl AjEApE8cwaAQ6hnGtUM/TWAb54E5/29ZVy5E/UY8mEzoE021pl3tq1fEof5qz5n/ KrL4AjAuEpVOjRrRWWMnRJxd05Pfxq7gZmxgwppjnE9JZ9P6WRP7ZWqZcc9p8YLM -YhKuXQo=`, "\n", "")) +YhKuXQo=` + +// testSPKIB64 contains the SubjectPublicKeyInfo from testCertB64. +const testSPKIB64 = ` +MHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEy5GNcAaxpffo4UePh0snEb+ZL1VfC1Dt +8WNbLcrvPqvZGkotN8K3R+y1H6yZEROkutF81V5Hqi/Tf/4zlxbGjPSW7G3hxUaA +MfpgRFevFbuvXW5cbVyACjZTDOXWr+Vz` + +// mustDecodeB64 decodes a base64 string that may contain embedded newlines. +func mustDecodeB64(t *testing.T, in string) []byte { + t.Helper() + out, err := base64.StdEncoding.DecodeString(strings.ReplaceAll(in, "\n", "")) if err != nil { t.Fatal(err) } + return out +} + +func TestFromX509(t *testing.T) { + input := mustDecodeB64(t, testCertB64) mtcle, err := FromX509(input, crypto.SHA256) if err != nil { @@ -57,23 +81,134 @@ AQUFBwMCMAwGA1UdEwEB/wQCMAAwHwYDVR0jBBgwFoAUgPmX4p5B3cSQ2bkaNcye } } -func TestFromX509Malformed(t *testing.T) { - valid, 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", "")) +func TestToTBSCertificate(t *testing.T) { + input := mustDecodeB64(t, testCertB64) + spki := mustDecodeB64(t, testSPKIB64) + + mtcle, err := FromX509(input, crypto.SHA256) + if err != nil { + t.Fatal(err) + } + + out, err := mtcle.ToTBSCertificate(8675309, spki, crypto.SHA256) if err != nil { t.Fatal(err) } + // Turn it into something that will parse with crypto/x509 + var builder cryptobyte.Builder + builder.AddASN1(asn1.SEQUENCE, func(certificate *cryptobyte.Builder) { + // tbsCertificate + certificate.AddBytes(out) + // signatureAlgorithm + certificate.AddBytes(proof.SigAlgEncoded()) + // signature + certificate.AddASN1BitString(nil) + }) + certBytes := builder.BytesOrPanic() + + cert, err := x509.ParseCertificate(certBytes) + if err != nil { + t.Fatalf("parsing ToMTC() output: %s", err) + } + orig, err := x509.ParseCertificate(input) + if err != nil { + t.Fatal(err) + } + + if cert.SerialNumber.Cmp(big.NewInt(8675309)) != 0 { + t.Errorf("serialNumber: got %v, want 8675309", cert.SerialNumber) + } + if !bytes.Equal(cert.RawIssuer, orig.RawIssuer) { + t.Errorf("issuer: got %x, want %x", cert.RawIssuer, orig.RawIssuer) + } + if !cert.NotBefore.Equal(orig.NotBefore) || !cert.NotAfter.Equal(orig.NotAfter) { + t.Errorf("validity: got %s/%s, want %s/%s", + cert.NotBefore, cert.NotAfter, orig.NotBefore, orig.NotAfter) + } + if !bytes.Equal(cert.RawSubject, orig.RawSubject) { + t.Errorf("subject: got %x, want %x", cert.RawSubject, orig.RawSubject) + } + if !bytes.Equal(cert.RawSubjectPublicKeyInfo, spki) { + t.Errorf("subjectPublicKeyInfo: got %x, want %x", cert.RawSubjectPublicKeyInfo, spki) + } + if !reflect.DeepEqual(cert.Extensions, orig.Extensions) { + t.Errorf("extensions: got %v, want %v", cert.Extensions, orig.Extensions) + } + + if !bytes.Contains(out, proof.SigAlgEncoded()) { + t.Errorf("output didn't contain id-alg-mtcProof") + } +} + +func TestToMTCMalformed(t *testing.T) { + input := mustDecodeB64(t, testCertB64) + spki := mustDecodeB64(t, testSPKIB64) + + valid, err := FromX509(input, crypto.SHA256) + if err != nil { + t.Fatal(err) + } + + // mutatedTBS returns a tbs_cert_entry whose TBSCertificateLogEntry bytes + // are a copy of the valid entry's, transformed by f. + mutatedTBS := func(f func([]byte) []byte) *MTCLogEntry { + return &MTCLogEntry{ + typ: typeTBSCertEntry, + value: f(bytes.Clone(valid.value)), + } + } + + // mutatedSPKI returns a copy of spki with the byte at index i flipped. + mutatedSPKI := func(i int) []byte { + out := bytes.Clone(spki) + out[i] ^= 0xff + return out + } + + type testCase struct { + name string + mtcle *MTCLogEntry + spki []byte + hash crypto.Hash + } + + testCases := []testCase{ + {"null entry", &MTCLogEntry{}, spki, crypto.SHA256}, + {"truncated TBS", mutatedTBS(func(v []byte) []byte { return v[:len(v)-2] }), spki, crypto.SHA256}, + {"trailing byte in TBS", mutatedTBS(func(v []byte) []byte { return append(v, 0) }), spki, crypto.SHA256}, + // The version is the first field of the TBSCertificateLogEntry, + // encoded as a0 03 02 01 02; overwrite the value byte (v2 not v3). + {"wrong version", mutatedTBS(func(v []byte) []byte { v[4] = 1; return v }), spki, crypto.SHA256}, + {"empty SPKI", valid, nil, crypto.SHA256}, + {"truncated SPKI", valid, spki[:8], crypto.SHA256}, + // Index 8 is inside the id-ecPublicKey OID within the SPKI's + // AlgorithmIdentifier, so it must fail the algorithm comparison. + {"SPKI with different algorithm", valid, mutatedSPKI(8), crypto.SHA256}, + // The last byte is inside the public key BIT STRING, so the + // AlgorithmIdentifiers match but the hash must not. + {"SPKI with different key", valid, mutatedSPKI(len(spki) - 1), crypto.SHA256}, + {"wrong hash function", valid, spki, crypto.SHA384}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + _, err := tc.mtcle.ToTBSCertificate(123, tc.spki, tc.hash) + if err == nil { + t.Errorf("ToMTC(): got nil err, want error") + } + }) + } + + // serialNumber must be a positive integer (RFC 5280 4.1.2.2). + if _, err := valid.ToTBSCertificate(0, spki, crypto.SHA256); err == nil { + t.Errorf("ToMTC() with zero serial: got nil err, want error") + } +} + +func TestFromX509Malformed(t *testing.T) { + valid := mustDecodeB64(t, testCertB64) + shortValid := valid[:len(valid)-2] longValid := append(valid, 0)