Created
September 16, 2024 01:40
-
-
Save liweitianux/ceff7dccd308a917e415dadb1c05418c to your computer and use it in GitHub Desktop.
OCSP Responder accompanying Pebble CA
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // SPDX-License-Identifier: MIT | |
| // | |
| // OCSP Responder accompanying Pebble CA. | |
| // | |
| // Aaron LI | |
| // 2024-09-13 | |
| // | |
| // Credits: | |
| // - How can a client get the revocation status of a certificate? | |
| // https://github.com/letsencrypt/pebble/issues/177#issuecomment-515928913 | |
| // - Create a new parameter in configuration file to set the OCSP Responder URL in certificates | |
| // https://github.com/letsencrypt/pebble/pull/242#issuecomment-505487998 | |
| // | |
| package main | |
| import ( | |
| "bytes" | |
| "crypto" | |
| "crypto/rand" | |
| "crypto/rsa" | |
| "crypto/sha1" | |
| "crypto/tls" | |
| "crypto/x509" | |
| "crypto/x509/pkix" | |
| "encoding/asn1" | |
| "encoding/hex" | |
| "encoding/json" | |
| "encoding/pem" | |
| "errors" | |
| "flag" | |
| "fmt" | |
| "io" | |
| "log" | |
| "math" | |
| "math/big" | |
| "net/http" | |
| "strconv" | |
| "strings" | |
| "time" | |
| "golang.org/x/crypto/ocsp" | |
| ) | |
| const ( | |
| // OCSP certificate Common Name prefix | |
| ocspNamePrefix = "Pebble OCSP " | |
| // Request path to fetch the OCSP key and certificate | |
| ocspCertPath = "/.certificate" | |
| ocspKeyPath = "/.key" | |
| ) | |
| func main() { | |
| addr := flag.String("addr", "0.0.0.0", "listening address") | |
| port := flag.Int("port", 80, "listening port") | |
| path := flag.String("path", "/", "serving path") | |
| pebbleURL := flag.String("pebble-url", "https://127.0.0.1:15000", "Pebble management URL") | |
| flag.Parse() | |
| ocsp := &ocspHandler{ | |
| pebbleURL: *pebbleURL, | |
| } | |
| if err := ocsp.initialize(); err != nil { | |
| log.Fatal(err) | |
| } | |
| log.Printf("OCSP initialized") | |
| if *path == "/" { | |
| http.Handle("/", ocsp) | |
| } else { | |
| *path = strings.TrimSuffix(*path, "/") | |
| http.Handle(*path, http.StripPrefix(*path, ocsp)) | |
| http.Handle(*path+"/", http.StripPrefix(*path, ocsp)) | |
| } | |
| listen := fmt.Sprintf("%s:%d", *addr, *port) | |
| log.Printf("Serving at: http://%s%s", listen, *path) | |
| log.Fatal(http.ListenAndServe(listen, nil)) | |
| } | |
| type ocspHandler struct { | |
| pebbleURL string | |
| // Intermediate CA certificate and key | |
| intermediateCert *x509.Certificate | |
| intermediateKey *rsa.PrivateKey | |
| // OCSP certificate issued from the above intermediate CA | |
| ocspCert *x509.Certificate | |
| ocspKey *rsa.PrivateKey | |
| } | |
| func (o *ocspHandler) initialize() error { | |
| o.pebbleURL = strings.TrimSuffix(o.pebbleURL, "/") | |
| log.Printf("Pebble management URL: %s", o.pebbleURL) | |
| if cert, err := getCert(o.pebbleURL + "/intermediates/0"); err != nil { | |
| log.Printf("ERROR: failed to get intermediate certificate") | |
| return err | |
| } else { | |
| o.intermediateCert = cert | |
| } | |
| if key, err := getKey(o.pebbleURL + "/intermediate-keys/0"); err != nil { | |
| log.Printf("ERROR: failed to get intermediate private key") | |
| return err | |
| } else { | |
| o.intermediateKey = key | |
| } | |
| if key, cert, err := makeKeyAndCert(o.intermediateCert, o.intermediateKey); err != nil { | |
| return err | |
| } else { | |
| o.ocspCert = cert | |
| o.ocspKey = key | |
| log.Printf("Created OCSP key and certificate [%s] from intermediate CA", | |
| cert.SerialNumber.Text(16)) | |
| } | |
| return nil | |
| } | |
| func (o *ocspHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) { | |
| //log.Printf("[DEBUG] request: %+v", req) | |
| switch req.URL.Path { | |
| case "", "/": | |
| o.handleOcsp(w, req) | |
| case ocspCertPath: | |
| o.handleFetchCert(w, req) | |
| case ocspKeyPath: | |
| o.handleFetchKey(w, req) | |
| default: | |
| http.Error(w, "404 not found", http.StatusNotFound) | |
| } | |
| } | |
| func (o *ocspHandler) handleFetchCert(w http.ResponseWriter, req *http.Request) { | |
| if req.Method != http.MethodGet { | |
| http.Error(w, "405 method not allowed", http.StatusMethodNotAllowed) | |
| return | |
| } | |
| var buf bytes.Buffer | |
| err := pem.Encode(&buf, &pem.Block{ | |
| Type: "CERTIFICATE", | |
| Bytes: o.ocspCert.Raw, | |
| }) | |
| if err != nil { | |
| http.Error(w, "500 internal server error", http.StatusInternalServerError) | |
| return | |
| } | |
| w.Header().Set("Content-Type", "application/pem-certificate-chain; charset=utf-8") | |
| w.WriteHeader(http.StatusOK) | |
| w.Write(buf.Bytes()) | |
| } | |
| func (o *ocspHandler) handleFetchKey(w http.ResponseWriter, req *http.Request) { | |
| if req.Method != http.MethodGet { | |
| http.Error(w, "405 method not allowed", http.StatusMethodNotAllowed) | |
| return | |
| } | |
| var buf bytes.Buffer | |
| err := pem.Encode(&buf, &pem.Block{ | |
| Type: "RSA PRIVATE KEY", | |
| Bytes: x509.MarshalPKCS1PrivateKey(o.ocspKey), | |
| }) | |
| if err != nil { | |
| http.Error(w, "500 internal server error", http.StatusInternalServerError) | |
| return | |
| } | |
| w.Header().Set("Content-Type", "application/x-pem-file; charset=utf-8") | |
| w.WriteHeader(http.StatusOK) | |
| w.Write(buf.Bytes()) | |
| } | |
| func (o *ocspHandler) handleOcsp(w http.ResponseWriter, req *http.Request) { | |
| if req.Method != http.MethodPost { | |
| http.Error(w, "405 method not allowed", http.StatusMethodNotAllowed) | |
| return | |
| } | |
| mediaType := "" | |
| if ct := req.Header.Get("Content-Type"); ct != "" { | |
| mediaType = strings.ToLower(strings.TrimSpace(strings.Split(ct, ";")[0])) | |
| } | |
| if mediaType != "application/ocsp-request" { | |
| http.Error(w, "400 bad request: Content-Type missing or invalid", | |
| http.StatusBadRequest) | |
| return | |
| } | |
| contentLength, err := strconv.Atoi(req.Header.Get("Content-Length")) | |
| if err != nil { | |
| http.Error(w, "400 bad request: Content-Length missing or invalid", | |
| http.StatusBadRequest) | |
| return | |
| } | |
| body := make([]byte, contentLength) | |
| if n, err := req.Body.Read(body); n < contentLength || err != io.EOF { | |
| log.Printf("ERROR: failed to read body: %v", err) | |
| http.Error(w, "400 bad request: body reading failure", | |
| http.StatusBadRequest) | |
| return | |
| } | |
| // XXX: Request Extensions (e.g., Nonce) are not supported. | |
| // See: https://github.com/grimm-co/GOCSP-responder/commit/a7dc72852c1f0431e97404f48c7259acaa20dfd8 | |
| oreq, err := ocsp.ParseRequest(body) | |
| if err != nil { | |
| log.Printf("ERROR: failed to parse OCSP request: %v", err) | |
| http.Error(w, "400 bad request: invalid OCSP request", | |
| http.StatusBadRequest) | |
| return | |
| } | |
| log.Printf("[INFO] Got OCSP request: %+v", oreq) | |
| template := ocsp.Response{ | |
| Status: ocsp.Good, | |
| SerialNumber: oreq.SerialNumber, | |
| Certificate: o.ocspCert, // NOTE: required! | |
| ThisUpdate: time.Now(), | |
| NextUpdate: time.Now().AddDate(0, 0, 1), | |
| } | |
| if cs, err := o.getCertStatus(oreq.SerialNumber); err != nil { | |
| template.Status = ocsp.Unknown | |
| } else if cs.Status == "Revoked" { | |
| template.Status = ocsp.Revoked | |
| template.RevocationReason = cs.Reason | |
| template.RevokedAt = cs.RevokedAt_ | |
| } | |
| //log.Printf("[DEBUG] OCSP response template: %+v", template) | |
| resp, err := ocsp.CreateResponse(o.intermediateCert, o.ocspCert, template, o.ocspKey) | |
| if err != nil { | |
| log.Printf("ERROR: failed to create OCSP response: %v", err) | |
| http.Error(w, "500 internal server error: ocsp response failure", | |
| http.StatusInternalServerError) | |
| return | |
| } | |
| w.Header().Set("Content-Type", "application/ocsp-response") | |
| w.WriteHeader(http.StatusOK) | |
| w.Write(resp) | |
| } | |
| type certStatus struct { | |
| Status string // Valid, Revoked | |
| Serial string // in hex string, without 0x prefix | |
| Certificate string // PEM-encoded certificate | |
| Certificate_ *x509.Certificate // parsed from Certificate | |
| Reason int // 0 if unspecified | |
| RevokedAt string // e.g., 2024-09-13 03:00:30.442029447 +0000 UTC | |
| RevokedAt_ time.Time // parsed from RevokedAt | |
| } | |
| func (o ocspHandler) getCertStatus(serial *big.Int) (*certStatus, error) { | |
| url := o.pebbleURL + "/cert-status-by-serial/" + serial.Text(16) | |
| body, err := request(url) | |
| if err != nil { | |
| return nil, err | |
| } | |
| cs := &certStatus{} | |
| if err := json.Unmarshal(body, cs); err != nil { | |
| log.Printf("ERROR: failed to unmarshal data: %s", string(body)) | |
| return nil, err | |
| } | |
| if cs.RevokedAt != "" { | |
| layout := "2006-01-02 15:04:05.999999999 -0700 MST" | |
| cs.RevokedAt_, err = time.Parse(layout, cs.RevokedAt) | |
| if err != nil { | |
| log.Printf("ERROR: failed to parse RevokedAt: %s", cs.RevokedAt) | |
| return nil, err | |
| } | |
| } | |
| block, _ := pem.Decode([]byte(cs.Certificate)) | |
| if block == nil { | |
| log.Printf("ERROR: failed to PEM decode data: %s", cs.Certificate) | |
| return nil, errors.New("bad PEM certificate") | |
| } | |
| cs.Certificate_, err = x509.ParseCertificate(block.Bytes) | |
| if err != nil { | |
| log.Printf("ERROR: failed to parse certificate: %s", cs.Certificate) | |
| return nil, err | |
| } | |
| log.Printf("[INFO] Got cert status: serial=%s, status=%s", cs.Serial, cs.Status) | |
| return cs, nil | |
| } | |
| //---------------------------------------------------------------------------- | |
| // Utilities | |
| var client = &http.Client{ | |
| Transport: &http.Transport{ | |
| TLSClientConfig: &tls.Config{ | |
| InsecureSkipVerify: true, | |
| }, | |
| }, | |
| Timeout: 2 * time.Second, | |
| } | |
| func request(url string) (body []byte, err error) { | |
| req, _ := http.NewRequest(http.MethodGet, url, nil) | |
| resp, err := client.Do(req) | |
| if err != nil { | |
| log.Printf("ERROR: failed to request %s: %s", url, err) | |
| return nil, err | |
| } | |
| defer resp.Body.Close() | |
| if status := resp.StatusCode; status != http.StatusOK { | |
| log.Printf("ERROR: bad status (%d) of request to %s", status, url) | |
| return nil, errors.New("bad status code") | |
| } | |
| body, err = io.ReadAll(resp.Body) | |
| if err != nil { | |
| log.Printf("ERROR: failed to read body of request to %s: %s", url, err) | |
| return nil, err | |
| } | |
| return body, nil | |
| } | |
| func getCert(url string) (*x509.Certificate, error) { | |
| data, err := request(url) | |
| if err != nil { | |
| return nil, err | |
| } | |
| block, _ := pem.Decode(data) | |
| if block == nil { | |
| log.Printf("ERROR: failed to PEM decode data: %s", string(data)) | |
| return nil, errors.New("bad PEM data") | |
| } | |
| cert, err := x509.ParseCertificate(block.Bytes) | |
| if err != nil { | |
| log.Printf("ERROR: failed to parse certificate: %s", string(data)) | |
| return nil, err | |
| } | |
| log.Printf("Obtained certificate from %s", url) | |
| return cert, nil | |
| } | |
| func getKey(url string) (*rsa.PrivateKey, error) { | |
| data, err := request(url) | |
| if err != nil { | |
| return nil, err | |
| } | |
| block, _ := pem.Decode(data) | |
| if block == nil { | |
| log.Printf("ERROR: failed to PEM decode data: %s", string(data)) | |
| return nil, errors.New("bad PEM data") | |
| } | |
| // First try PKCS1 (RSA PRIVATE KEY) | |
| if key, err := x509.ParsePKCS1PrivateKey(block.Bytes); err == nil { | |
| log.Printf("Obtained PKCS1 private key from %s", url) | |
| return key, nil | |
| } | |
| // Fallback to PKCS8 (PRIVATE KEY) | |
| if key, err := x509.ParsePKCS8PrivateKey(block.Bytes); err == nil { | |
| log.Printf("Obtained PKCS1 private key from %s", url) | |
| rsaKey := key.(*rsa.PrivateKey) | |
| return rsaKey, nil | |
| } | |
| log.Printf("ERROR: failed to get private key from %s", url) | |
| return nil, errors.New("invalid private key") | |
| } | |
| // From pebble/ca/ca.go | |
| func makeSerial() *big.Int { | |
| serial, err := rand.Int(rand.Reader, big.NewInt(math.MaxInt64)) | |
| if err != nil { | |
| panic(fmt.Sprintf("unable to create random serial number: %s", err.Error())) | |
| } | |
| return serial | |
| } | |
| // From pebble/ca/ca.go | |
| // Adapted from MiniCA: https://github.com/jsha/minica/blob/3a621c05b61fa1c24bcb42fbde4b261db504a74f/main.go | |
| func makeKey() (*rsa.PrivateKey, []byte, error) { | |
| key, err := rsa.GenerateKey(rand.Reader, 2048) | |
| if err != nil { | |
| return nil, nil, err | |
| } | |
| ski, err := makeSubjectKeyID(key.Public()) | |
| if err != nil { | |
| return nil, nil, err | |
| } | |
| return key, ski, nil | |
| } | |
| // From pebble/ca/ca.go | |
| // Taken from https://github.com/cloudflare/cfssl/blob/b94e044bb51ec8f5a7232c71b1ed05dbe4da96ce/signer/signer.go#L221-L244 | |
| func makeSubjectKeyID(key crypto.PublicKey) ([]byte, error) { | |
| // Marshal the public key as ASN.1 | |
| pubAsDER, err := x509.MarshalPKIXPublicKey(key) | |
| if err != nil { | |
| return nil, err | |
| } | |
| // Unmarshal it again so we can extract the key bitstring bytes | |
| var pubInfo struct { | |
| Algorithm pkix.AlgorithmIdentifier | |
| SubjectPublicKey asn1.BitString | |
| } | |
| _, err = asn1.Unmarshal(pubAsDER, &pubInfo) | |
| if err != nil { | |
| return nil, err | |
| } | |
| // Hash it according to https://tools.ietf.org/html/rfc5280#section-4.2.1.2 Method #1: | |
| ski := sha1.Sum(pubInfo.SubjectPublicKey.Bytes) | |
| return ski[:], nil | |
| } | |
| func makeKeyAndCert( | |
| issuer *x509.Certificate, | |
| signKey *rsa.PrivateKey, | |
| ) (*rsa.PrivateKey, *x509.Certificate, error) { | |
| key, ski, err := makeKey() | |
| if err != nil { | |
| log.Printf("ERROR: failed to create OCSP key: %s", err) | |
| return nil, nil, err | |
| } | |
| serial := makeSerial() | |
| id := hex.EncodeToString(serial.Bytes()[:3]) | |
| subject := pkix.Name{ | |
| CommonName: ocspNamePrefix + id, | |
| } | |
| template := &x509.Certificate{ | |
| Subject: subject, | |
| SerialNumber: serial, | |
| NotBefore: time.Now(), | |
| NotAfter: time.Now().AddDate(1, 0, 0), | |
| KeyUsage: x509.KeyUsageDigitalSignature, | |
| ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageOCSPSigning}, | |
| SubjectKeyId: ski, | |
| BasicConstraintsValid: true, | |
| IsCA: false, | |
| } | |
| der, err := x509.CreateCertificate(rand.Reader, template, issuer, key.Public(), signKey) | |
| if err != nil { | |
| log.Printf("ERROR: failed to create OCSP certificate: %s", err) | |
| return nil, nil, err | |
| } | |
| cert, err := x509.ParseCertificate(der) | |
| if err != nil { | |
| log.Printf("ERROR: failed to parse OCSP certificate: %s", err) | |
| return nil, nil, err | |
| } | |
| return key, cert, nil | |
| } |
Author
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Build and start the OCSP responder:
Example queries:
Example responder output: