github.com/letsencrypt/boulder@v0.20251208.0/test/ct-test-srv/main.go (about) 1 // This is a test server that implements the subset of RFC6962 APIs needed to 2 // run Boulder's CT log submission code. Currently it only implements add-chain. 3 // This is used by startservers.py. 4 package main 5 6 import ( 7 "crypto/ecdsa" 8 "crypto/sha256" 9 "crypto/x509" 10 "encoding/base64" 11 "encoding/json" 12 "flag" 13 "fmt" 14 "io" 15 "log" 16 "math/rand/v2" 17 "net/http" 18 "os" 19 "strings" 20 "sync" 21 "time" 22 23 "github.com/letsencrypt/boulder/cmd" 24 "github.com/letsencrypt/boulder/publisher" 25 ) 26 27 type ctSubmissionRequest struct { 28 Chain []string `json:"chain"` 29 } 30 31 type integrationSrv struct { 32 sync.Mutex 33 submissions map[string]int64 34 // Hostnames where we refuse to provide an SCT. This is to exercise the code 35 // path where all CT servers fail. 36 rejectHosts map[string]bool 37 // A list of entries that we rejected based on rejectHosts. 38 rejected []string 39 key *ecdsa.PrivateKey 40 flakinessRate int 41 userAgent string 42 } 43 44 func readJSON(r *http.Request, output any) error { 45 if r.Method != "POST" { 46 return fmt.Errorf("incorrect method; only POST allowed") 47 } 48 bodyBytes, err := io.ReadAll(r.Body) 49 if err != nil { 50 return err 51 } 52 53 err = json.Unmarshal(bodyBytes, output) 54 if err != nil { 55 return err 56 } 57 return nil 58 } 59 60 func (is *integrationSrv) addChain(w http.ResponseWriter, r *http.Request) { 61 is.addChainOrPre(w, r, false) 62 } 63 64 // addRejectHost takes a JSON POST with a "host" field; any subsequent 65 // submissions for that host will get a 400 error. 66 func (is *integrationSrv) addRejectHost(w http.ResponseWriter, r *http.Request) { 67 var rejectHostReq struct { 68 Host string 69 } 70 err := readJSON(r, &rejectHostReq) 71 if err != nil { 72 http.Error(w, err.Error(), http.StatusBadRequest) 73 return 74 } 75 76 is.Lock() 77 defer is.Unlock() 78 is.rejectHosts[rejectHostReq.Host] = true 79 w.Write([]byte{}) 80 } 81 82 // getRejections returns a JSON array containing strings; those strings are 83 // base64 encodings of certificates or precertificates that were rejected due to 84 // the rejectHosts mechanism. 85 func (is *integrationSrv) getRejections(w http.ResponseWriter, r *http.Request) { 86 is.Lock() 87 defer is.Unlock() 88 output, err := json.Marshal(is.rejected) 89 if err != nil { 90 http.Error(w, err.Error(), http.StatusBadRequest) 91 return 92 } 93 94 w.WriteHeader(http.StatusOK) 95 w.Write(output) 96 } 97 98 // shouldReject checks if the given host is in the rejectHosts list for the 99 // integrationSrv. If it is, then the chain is appended to the integrationSrv 100 // rejected list and true is returned indicating the request should be rejected. 101 func (is *integrationSrv) shouldReject(host, chain string) bool { 102 is.Lock() 103 defer is.Unlock() 104 if is.rejectHosts[host] { 105 is.rejected = append(is.rejected, chain) 106 return true 107 } 108 return false 109 } 110 111 func (is *integrationSrv) addPreChain(w http.ResponseWriter, r *http.Request) { 112 is.addChainOrPre(w, r, true) 113 } 114 115 func (is *integrationSrv) addChainOrPre(w http.ResponseWriter, r *http.Request, precert bool) { 116 if is.userAgent != "" && r.UserAgent() != is.userAgent { 117 http.Error(w, "invalid user-agent", http.StatusBadRequest) 118 return 119 } 120 if r.Method != "POST" { 121 http.NotFound(w, r) 122 return 123 } 124 bodyBytes, err := io.ReadAll(r.Body) 125 if err != nil { 126 http.Error(w, err.Error(), http.StatusBadRequest) 127 return 128 } 129 130 var addChainReq ctSubmissionRequest 131 err = json.Unmarshal(bodyBytes, &addChainReq) 132 if err != nil { 133 http.Error(w, err.Error(), http.StatusBadRequest) 134 return 135 } 136 if len(addChainReq.Chain) == 0 { 137 w.WriteHeader(400) 138 return 139 } 140 141 b, err := base64.StdEncoding.DecodeString(addChainReq.Chain[0]) 142 if err != nil { 143 w.WriteHeader(400) 144 return 145 } 146 cert, err := x509.ParseCertificate(b) 147 if err != nil { 148 w.WriteHeader(400) 149 return 150 } 151 hostnames := strings.Join(cert.DNSNames, ",") 152 153 for _, h := range cert.DNSNames { 154 if is.shouldReject(h, addChainReq.Chain[0]) { 155 w.WriteHeader(400) 156 return 157 } 158 } 159 160 is.Lock() 161 is.submissions[hostnames]++ 162 is.Unlock() 163 164 if is.flakinessRate != 0 && rand.IntN(100) < is.flakinessRate { 165 time.Sleep(10 * time.Second) 166 } 167 168 w.WriteHeader(http.StatusOK) 169 w.Write(publisher.CreateTestingSignedSCT(addChainReq.Chain, is.key, precert, time.Now())) 170 } 171 172 func (is *integrationSrv) getSubmissions(w http.ResponseWriter, r *http.Request) { 173 if r.Method != "GET" { 174 http.NotFound(w, r) 175 return 176 } 177 178 is.Lock() 179 hostnames := r.URL.Query().Get("hostnames") 180 submissions := is.submissions[hostnames] 181 is.Unlock() 182 183 w.WriteHeader(http.StatusOK) 184 fmt.Fprintf(w, "%d", submissions) 185 } 186 187 type config struct { 188 Personalities []Personality 189 } 190 191 type Personality struct { 192 // If present, the expected UserAgent of the reporter to this test CT log. 193 UserAgent string 194 // Port (and optionally IP) to listen on 195 Addr string 196 // Private key for signing SCTs 197 // Generate your own with: 198 // openssl ecparam -name prime256v1 -genkey -outform der -noout | base64 -w 0 199 PrivKey string 200 // FlakinessRate is an integer between 0-100 that controls how often the log 201 // "flakes", i.e. fails to respond in a reasonable time frame. 202 FlakinessRate int 203 } 204 205 func runPersonality(p Personality) { 206 keyDER, err := base64.StdEncoding.DecodeString(p.PrivKey) 207 if err != nil { 208 log.Fatal(err) 209 } 210 key, err := x509.ParseECPrivateKey(keyDER) 211 if err != nil { 212 log.Fatal(err) 213 } 214 pubKeyBytes, err := x509.MarshalPKIXPublicKey(&key.PublicKey) 215 if err != nil { 216 log.Fatal(err) 217 } 218 is := integrationSrv{ 219 key: key, 220 flakinessRate: p.FlakinessRate, 221 submissions: make(map[string]int64), 222 rejectHosts: make(map[string]bool), 223 userAgent: p.UserAgent, 224 } 225 m := http.NewServeMux() 226 m.HandleFunc("/submissions", is.getSubmissions) 227 m.HandleFunc("/ct/v1/add-pre-chain", is.addPreChain) 228 m.HandleFunc("/ct/v1/add-chain", is.addChain) 229 m.HandleFunc("/add-reject-host", is.addRejectHost) 230 m.HandleFunc("/get-rejections", is.getRejections) 231 srv := &http.Server{ //nolint: gosec // No ReadHeaderTimeout is fine for test-only code. 232 Addr: p.Addr, 233 Handler: m, 234 } 235 logID := sha256.Sum256(pubKeyBytes) 236 log.Printf("ct-test-srv on %s with pubkey: %s, log ID: %s, flakiness: %d%%", p.Addr, 237 base64.StdEncoding.EncodeToString(pubKeyBytes), base64.StdEncoding.EncodeToString(logID[:]), p.FlakinessRate) 238 log.Fatal(srv.ListenAndServe()) 239 } 240 241 func main() { 242 configFile := flag.String("config", "", "Path to config file.") 243 flag.Parse() 244 data, err := os.ReadFile(*configFile) 245 if err != nil { 246 log.Fatal(err) 247 } 248 var c config 249 err = json.Unmarshal(data, &c) 250 if err != nil { 251 log.Fatal(err) 252 } 253 254 for _, p := range c.Personalities { 255 go runPersonality(p) 256 } 257 cmd.WaitForSignal() 258 }