Fooling Go's X.509 Certificate Verification
quality 7/10 · good
0 net
Fooling Go's X.509 Certificate Verification · Daniel Mangum Below are two X.509 certificates. The first is the Certificate Authority (CA) root certificate, and the second is a leaf certifcate signed by the private key of the CA. ca.crt.pem -----BEGIN CERTIFICATE----- MIIBejCCASGgAwIBAgIUda4UvlFzwQEO/fD0f4hAnj+ydPYwCgYIKoZIzj0EAwIw EjEQMA4GA1UEAxMHUm9vdCBDQTAgFw0yNjAyMjcxOTQ3NDZaGA8yMTI2MDIwMzE5 NDc0NlowEjEQMA4GA1UEAxMHUm9vdCBDQTBZMBMGByqGSM49AgEGCCqGSM49AwEH A0IABKL5BB9aaQ2TtNgUymEsa/+s2ZlTXVll0N22KKWxh0N/JdgHcjrKfzqRlVrt UN2GXdvsdLOq15TxBq97WvE07lKjUzBRMB0GA1UdDgQWBBTAVEw9doSzY1DuPVxP EnwEp/+VJDAfBgNVHSMEGDAWgBTAVEw9doSzY1DuPVxPEnwEp/+VJDAPBgNVHRMB Af8EBTADAQH/MAoGCCqGSM49BAMCA0cAMEQCIHrSTk/KJHAjn3MC/egvfxMM1NpG GEzMB7EH+VXWz7RfAiAyhwy4E9hc8/qsTI+4iKf2o/zMRu5H2GNJOLqOngglbQ== -----END CERTIFICATE----- leaf.crt.pem -----BEGIN CERTIFICATE----- MIIBHjCBxAIULE3hvnYxU91g9c9H3+uGCSqXi4MwCgYIKoZIzj0EAwIwEjEQMA4G A1UEAwwHUm9vdCBDQTAgFw0yNjAyMjcxOTQ3NDZaGA8yMTI2MDIwMzE5NDc0Nlow DzENMAsGA1UEAwwEbGVhZjBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABKDZ21Yh +1AQp1TrxrS8FquIVEHrFRSXncX9xl5vVhZFqvblzTp2Tg7TER5x7rHG1TIqQL1z xDX4TB+nZOWkyAcwCgYIKoZIzj0EAwIDSQAwRgIhAMeo5t2d1RWL/SB0E+mvvIZP jFT0wDWX1Bm26MtxRcf9AiEApG96fs70WF1JliFgzkTiNvbG7Gj4SvErZ9nNX/Lr PnA= -----END CERTIFICATE----- If you downloaded these certificates, you could visually see that the latter references the former as its Issuer. If you were to use a tool like openssl to verify that the leaf is signed by the private key of root, you would see that it is. Unless of course you are reading this blog post from the year 2126 or you have changed the system time on your machine. If the former, I am exceedingly dissapointed that humanity is still using openssl . openssl verify -CAfile ca.crt.pem leaf.crt.pem Now, if you wanted to write a Go program that verified this chain of trust, it might look something like the following. main.go package main import ( "encoding/pem" "crypto/x509" "fmt" "os" "time" ) func main() { b, err := os.ReadFile( "ca.crt.pem" ) if err != nil { panic (err) } block, _ := pem.Decode(b) ca, err := x509.ParseCertificate(block.Bytes) if err != nil { panic (err) } b, err = os.ReadFile( "leaf.crt.pem" ) if err != nil { panic (err) } block, _ = pem.Decode(b) lc, err := x509.ParseCertificate(block.Bytes) if err != nil { panic (err) } roots := x509.NewCertPool() roots.AddCert(ca) opts := x509.VerifyOptions{ Roots: roots, CurrentTime: time.Now(), } if _, err := lc.Verify(opts); err != nil { panic (err) } fmt.Println( "Certificate verification successful." ) } But if you ran that program, you might be surprised to see the following. panic: x509: certificate signed by unknown authority If you used this CA certificate instead, you would see the expected output. ca.verifies.crt.pem -----BEGIN CERTIFICATE----- MIIBejCCASGgAwIBAgIUda4UvlFzwQEO/fD0f4hAnj+ydPYwCgYIKoZIzj0EAwIw EjEQMA4GA1UEAwwHUm9vdCBDQTAgFw0yNjAyMjcxOTQ3NDZaGA8yMTI2MDIwMzE5 NDc0NlowEjEQMA4GA1UEAwwHUm9vdCBDQTBZMBMGByqGSM49AgEGCCqGSM49AwEH A0IABKL5BB9aaQ2TtNgUymEsa/+s2ZlTXVll0N22KKWxh0N/JdgHcjrKfzqRlVrt UN2GXdvsdLOq15TxBq97WvE07lKjUzBRMB0GA1UdDgQWBBTAVEw9doSzY1DuPVxP EnwEp/+VJDAfBgNVHSMEGDAWgBTAVEw9doSzY1DuPVxPEnwEp/+VJDAPBgNVHRMB Af8EBTADAQH/MAoGCCqGSM49BAMCA0cAMEQCIHrSTk/KJHAjn3MC/egvfxMM1NpG GEzMB7EH+VXWz7RfAiAyhwy4E9hc8/qsTI+4iKf2o/zMRu5H2GNJOLqOngglbQ== -----END CERTIFICATE----- Certificate verification successful. At first glance these certificates appear to be identical. You could use openssl to view the contents of both certificates, and you would get identical output. openssl x509 -in ca.crt.pem -noout -text Certificate: Data: Version: 3 (0x2) Serial Number: 75:ae:14:be:51:73:c1:01:0e:fd:f0:f4:7f:88:40:9e:3f:b2:74:f6 Signature Algorithm: ecdsa-with-SHA256 Issuer: CN = Root CA Validity Not Before: Feb 27 19:47:46 2026 GMT Not After : Feb 3 19:47:46 2126 GMT Subject: CN = Root CA Subject Public Key Info: Public Key Algorithm: id-ecPublicKey Public-Key: (256 bit) pub: 04:a2:f9:04:1f:5a:69:0d:93:b4:d8:14:ca:61:2c: 6b:ff:ac:d9:99:53:5d:59:65:d0:dd:b6:28:a5:b1: 87:43:7f:25:d8:07:72:3a:ca:7f:3a:91:95:5a:ed: 50:dd:86:5d:db:ec:74:b3:aa:d7:94:f1:06:af:7b: 5a:f1:34:ee:52 ASN1 OID: prime256v1 NIST CURVE: P-256 X509v3 extensions: X509v3 Subject Key Identifier: C0:54:4C:3D:76:84:B3:63:50:EE:3D:5C:4F:12:7C:04:A7:FF:95:24 X509v3 Authority Key Identifier: C0:54:4C:3D:76:84:B3:63:50:EE:3D:5C:4F:12:7C:04:A7:FF:95:24 X509v3 Basic Constraints: critical CA:TRUE Signature Algorithm: ecdsa-with-SHA256 Signature Value: 30:44:02:20:7a:d2:4e:4f:ca:24:70:23:9f:73:02:fd:e8:2f: 7f:13:0c:d4:da:46:18:4c:cc:07:b1:07:f9:55:d6:cf:b4:5f: 02:20:32:87:0c:b8:13:d8:5c:f3:fa:ac:4c:8f:b8:88:a7:f6: a3:fc:cc:46:ee:47:d8:63:49:38:ba:8e:9e:08:25:6d However, if you were to compare the bytes of the certificates, you would see that there is a very slight difference; two bytes to be exact. diff <(openssl x509 -in ca.crt.pem -outform der | xxd) <(openssl x509 -in ca.verifies.crt.pem -outform der | xxd) 4c4 < 00000030: 1231 1030 0e06 0355 0403 1307 526f 6f74 .1.0...U....Root --- > 00000030: 1231 1030 0e06 0355 0403 0c07 526f 6f74 .1.0...U....Root 8c8 < 00000070: 1307 526f 6f74 2043 4130 5930 1306 072a ..Root CA0Y0...* --- > 00000070: 0c07 526f 6f74 2043 4130 5930 1306 072a ..Root CA0Y0...* In both locations, there is a 0x13 byte in the certificate which failed verification with the Go program ( ca.crt.pem ), and a 0x0c byte in the certificate that passed ( ca.verifies.crt.pem ). If you are familiar with X.509 certificates, you’ll know that they are defined using Abstract Syntax Notation One (ASN.1) and are binary encoded using Distinguished Encoding Rules (DER) . They are frequently Base64 encoded and stored and transmitted as Privacy-enhanced Mail (PEM) text files (which you have already seen in this post). The ASN.1 specification defines a set of data types, each with an associated tag (non-negative integer identifier), which precedes the length and the value when using DER encoding (see this post from Let’s Encrypt for more information). openssl can once again be used to see the data types of different fields in the certificate that is successfully verified. openssl asn1parse -in ca.verifies.crt.pem 0:d=0 hl=4 l= 378 cons: SEQUENCE 4:d=1 hl=4 l= 289 cons: SEQUENCE 8:d=2 hl=2 l= 3 cons: cont [ 0 ] 10:d=3 hl=2 l= 1 prim: INTEGER :02 13:d=2 hl=2 l= 20 prim: INTEGER :75AE14BE5173C1010EFDF0F47F88409E3FB274F6 35:d=2 hl=2 l= 10 cons: SEQUENCE 37:d=3 hl=2 l= 8 prim: OBJECT :ecdsa-with-SHA256 47:d=2 hl=2 l= 18 cons: SEQUENCE 49:d=3 hl=2 l= 16 cons: SET 51:d=4 hl=2 l= 14 cons: SEQUENCE 53:d=5 hl=2 l= 3 prim: OBJECT :commonName 58:d=5 hl=2 l= 7 prim: UTF8STRING :Root CA 67:d=2 hl=2 l= 32 cons: SEQUENCE 69:d=3 hl=2 l= 13 prim: UTCTIME :260227194746Z 84:d=3 hl=2 l= 15 prim: GENERALIZEDTIME :21260203194746Z 101:d=2 hl=2 l= 18 cons: SEQUENCE 103:d=3 hl=2 l= 16 cons: SET 105:d=4 hl=2 l= 14 cons: SEQUENCE 107:d=5 hl=2 l= 3 prim: OBJECT :commonName 112:d=5 hl=2 l= 7 prim: UTF8STRING :Root CA 121:d=2 hl=2 l= 89 cons: SEQUENCE 123:d=3 hl=2 l= 19 cons: SEQUENCE 125:d=4 hl=2 l= 7 prim: OBJECT :id-ecPublicKey 134:d=4 hl=2 l= 8 prim: OBJECT :prime256v1 144:d=3 hl=2 l= 66 prim: BIT STRING 212:d=2 hl=2 l= 83 cons: cont [ 3 ] 214:d=3 hl=2 l= 81 cons: SEQUENCE 216:d=4 hl=2 l= 29 cons: SEQUENCE 218:d=5 hl=2 l= 3 prim: OBJECT :X509v3 Subject Key Identifier 223:d=5 hl=2 l= 22 prim: OCTET STRING [HEX DUMP]:0414C0544C3D7684B36350EE3D5C4F127C04A7FF9524 247:d=4 hl=2 l= 31 cons: SEQUENCE 249:d=5 hl=2 l= 3 prim: OBJECT :X509v3 Authority Key Identifier 254:d=5 hl=2 l= 24 prim: OCTET STRING [HEX DUMP]:30168014C0544C3D7684B36350EE3D5C4F127C04A7FF9524 280:d=4 hl=2 l= 15 cons: SEQUENCE 282:d=5 hl=2 l= 3 prim: OBJECT :X509v3 Basic Constraints 287:d=5 hl=2 l= 1 prim: BOOLEAN :255 290:d=5 hl=2 l= 5 prim: OCTET STRING [HEX DUMP]:30030101FF 297:d=1 hl=2 l= 10 cons: SEQUENCE 299:d=2 hl=2 l= 8 prim: OBJECT :ecdsa-with-SHA256 309:d=1 hl=2 l= 71 prim: BIT STRING In the diff view of the two CA certificates, the bytes that differed preceded the Root CA string in two different places: the Subject and the Issuer, which are the same since this is a self-signed certificate. They were also followed by a 0x07 byte, which aligns with the number of characters in Root CA (i.e. the length of the value). The differing leading byte suggests differing ASN.1 data types for these fields. The CA certificate for which validation is successful uses UTF8String ( 0x0c ), and you can use openssl with the failing CA certificate to see that it uses PrintableString instead ( 0x13 ). openssl asn1parse -in ca.crt.pem 0:d=0 hl=4 l= 378 cons: SEQUENCE 4:d=1 hl=4 l= 289 cons: SEQUENCE 8:d=2 hl=2 l= 3 cons: cont [ 0 ] 10:d=3 hl=2 l= 1 prim: INTEGER :02 13:d=2 hl=2 l= 20 prim: INTEGER :75AE14BE5173C1010EFDF0F47F88409E3FB274F6 35:d=2 hl=2 l= 10 cons: SEQUENCE 37:d=3 hl=2 l= 8 prim: OBJECT :ecdsa-with-SHA256 47:d=2 hl=2 l= 18 cons: SEQUENCE 49:d=3 hl=2 l= 16 cons: SET 51:d=4 hl=2 l= 14 cons: SEQUENCE 53:d=5 hl=2 l= 3 prim: OBJECT :commonName 58:d=5 hl=2 l= 7 prim: PRINTABLESTRING :Root CA 67:d=2 hl=2 l= 32 cons: SEQUENCE 69:d=3 hl=2 l= 13 prim: UTCTIME :260227194746Z 84:d=3 hl=2 l= 15 prim: GENERALIZEDTIME :21260203194746Z 101:d=2 hl=2 l= 18 cons: SEQUENCE 103:d=3 hl=2 l= 16 cons: SET 105:d=4 hl=2 l= 14 cons: SEQUENCE 107:d=5 hl=2 l= 3 prim: OBJECT :commonName 112:d=5 hl=2 l= 7 prim: PRINTABLESTRING :Root CA 121:d=2 hl=2 l= 89 cons: SEQUENCE 123:d=3 hl=2 l= 19 cons: SEQUENCE 125:d=4 hl=2 l= 7 prim: OBJECT :id-ecPublicKey 134:d=4 hl=2 l= 8 prim: OBJECT :prime256v1 144:d=3 hl=2 l= 66 prim: BIT STRING 212:d=2 hl=2 l= 83 cons: cont [ 3 ] 214:d=3 hl=2 l= 81 cons: SEQUENCE 216:d=4 hl=2 l= 29 cons: SEQUENCE 218:d=5 hl=2 l= 3 prim: OBJECT :X509v3 Subject Key Identifier 223:d=5 hl=2 l= 22 prim: OCTET STRING [HEX DUMP]:0414C0544C3D7684B36350EE3D5C4F127C04A7FF9524 247:d=4 hl=2 l= 31 cons: SEQUENCE 249:d=5 hl=2 l= 3 prim: OBJECT :X509v3 Authority Key Identifier 254:d=5 hl=2 l= 24 prim: OCTET STRING [HEX DUMP]:30168014C0544C3D7684B36350EE3D5C4F127C04A7FF9524 280:d=4 hl=2 l= 15 cons: SEQUENCE 282:d=5 hl=2 l= 3 prim: OBJECT :X509v3 Basic Constraints 287:d=5 hl=2 l= 1 prim: BOOLEAN :255 290:d=5 hl=2 l= 5 prim: OCTET STRING [HEX DUMP]:30030101FF 297:d=1 hl=2 l= 10 cons: SEQUENCE 299:d=2 hl=2 l= 8 prim: OBJECT :ecdsa-with-SHA256 309:d=1 hl=2 l= 71 prim: BIT STRING This still doesn’t explain why openssl verifies successfully with either CA certificate, while the Go program does not. To dig further, you can compile and step through the program with gdb , starting with a breakpoint on Verify() . gdb main -ex 'b crypto/x509.(*Certificate).Verify' -ex 'run' Stepping through the function , you eventually arrive at the point where you are building the candidate certificate chains. Add a breakpoint for this function using b crypto/x509.(*Certificate).buildChains . var candidateChains [][]*Certificate if opts.Roots.contains(c) { candidateChains = [][]*Certificate{{c}} } else { candidateChains, err = c.buildChains([]*Certificate{c}, nil , &opts) if err != nil { return nil , err } } As part of evaluating whether the certificate pool provided has a candidate chain, findPotentialParents() is called on the Roots (it is also called on the Intermediates , but there are no intermediate certificates provided in this example). for _, root := range opts.Roots.findPotentialParents(c) { considerCandidate(rootCertificate, root) } Finally, you arrive at the source of the failure . A potential parent for the leaf certificate should have a Subject that matches the Issuer of the leaf (i.e. the leaf should refer to it as the certificate that was used for signing). for _, c := range s.byName[ string (cert.RawIssuer)] { candidate, constraint, err := s.cert(c) if err != nil { continue } kidMatch := bytes.Equal(candidate.SubjectKeyId, cert.AuthorityKeyId) switch { case kidMatch: matchingKeyID = append (matchingKeyID, potentialParent{candidate, constraint}) case ( len (candidate.SubjectKeyId) == 0 && len (cert.AuthorityKeyId) > 0 ) || ( len (candidate.SubjectKeyId) > 0 && len (cert.AuthorityKeyId) == 0 ): oneKeyID = append (oneKeyID, potentialParent{candidate, constraint}) default : mismatchKeyID = append (mismatchKeyID, potentialParent{candidate, constraint}) } } The keys in the byName map of a CertPool contain the Subject of the CA certificates. When using the CA certificate that caused verification failure, stepping through the loop above you can see that there are zero iterations, or, that there are no CA certificates with a Subject that matches the leaf Issuer. How could that be? The key observation is that the raw Subject and Issuer, the literal bytes, are being used for comparison. // CertPool is a set of certificates. type CertPool struct { byName map [ string ][] int // cert.RawSubject => index into lazyCerts // lazyCerts contains funcs that return a certificate, // lazily parsing/decompressing it as needed. lazyCerts []lazyCert // haveSum maps from sum224(cert.Raw) to true. It's used only // for AddCert duplicate detection, to avoid CertPool.contains // calls in the AddCert path (because the contains method can // call getCert and otherwise negate savings from lazy getCert // funcs). haveSum map [sum224] bool // systemPool indicates whether this is a special pool derived from the // system roots. If it includes additional roots, it requires doing two // verifications, one using the roots provided by the caller, and one using // the system platform verifier. systemPool bool } We saw earlier that the two CA certificates differed in the ASN.1 data types used for their Subject. Expectedly, if you check the data type of the Issuer in the leaf certificate, you’ll see that it is a UTF8String , matching the CA certificate that was verified successfully. openssl asn1parse -in leaf.crt.pem 0:d=0 hl=4 l= 286 cons: SEQUENCE 4:d=1 hl=3 l= 196 cons: SEQUENCE 7:d=2 hl=2 l= 20 prim: INTEGER :2C4DE1BE763153DD60F5CF47DFEB86092A978B83 29:d=2 hl=2 l= 10 cons: SEQUENCE 31:d=3 hl=2 l= 8 prim: OBJECT :ecdsa-with-SHA256 41:d=2 hl=2 l= 18 cons: SEQUENCE 43:d=3 hl=2 l= 16 cons: SET 45:d=4 hl=2 l= 14 cons: SEQUENCE 47:d=5 hl=2 l= 3 prim: OBJECT :commonName 52:d=5 hl=2 l= 7 prim: UTF8STRING :Root CA 61:d=2 hl=2 l= 32 cons: SEQUENCE 63:d=3 hl=2 l= 13 prim: UTCTIME :260227194746Z 78:d=3 hl=2 l= 15 prim: GENERALIZEDTIME :21260203194746Z 95:d=2 hl=2 l= 15 cons: SEQUENCE 97:d=3 hl=2 l= 13 cons: SET 99:d=4 hl=2 l= 11 cons: SEQUENCE 101:d=5 hl=2 l= 3 prim: OBJECT :commonName 106:d=5 hl=2 l= 4 prim: UTF8STRING :leaf 112:d=2 hl=2 l= 89 cons: SEQUENCE 114:d=3 hl=2 l= 19 cons: SEQUENCE 116:d=4 hl=2 l= 7 prim: OBJECT :id-ecPublicKey 125:d=4 hl=2 l= 8 prim: OBJECT :prime256v1 135:d=3 hl=2 l= 66 prim: BIT STRING 203:d=1 hl=2 l= 10 cons: SEQUENCE 205:d=2 hl=2 l= 8 prim: OBJECT :ecdsa-with-SHA256 215:d=1 hl=2 l= 73 prim: BIT STRING Whether this is the correct behavior has been an ongoing debate in the Go project, and the matter is complicated by some tools, such as openssl as seen in this post, treating different ASN.1 data types for strings as equivalent when verifying certificates. Typically you’ll be using the same tooling or services for generating CA certificates and the leaf certificates they sign, so the encoding will likely be consistent. However, given that leaf certificates are typically much shorter lived than CA certificates, your tooling may evolve over time, potentially causing discrepancies in newly generated leaves. Though Go’s handling of this scenario results in fail-closed behavior, it can still cause outages and downtime, making it important to be aware of how you are generating certificates and how they are expected to be verified.