Skip to content

Commit ba7a8e8

Browse files
Roland Bracewell Shoemakercpu
authored andcommitted
Add fake Akamai purge server for integration testing (letsencrypt#3946)
Fixes letsencrypt#3916.
1 parent 965acf3 commit ba7a8e8

8 files changed

Lines changed: 196 additions & 37 deletions

File tree

akamai/cache-client.go

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -299,3 +299,34 @@ func (cpc *CachePurgeClient) Purge(urls []string) error {
299299
}
300300
return nil
301301
}
302+
303+
// CheckSignature is used for tests, it exported so that it can be used in akamai-test-srv
304+
func CheckSignature(secret string, url string, r *http.Request, body []byte) error {
305+
bodyHash := sha256.Sum256(body)
306+
bodyHashB64 := base64.StdEncoding.EncodeToString(bodyHash[:])
307+
308+
authorization := r.Header.Get("Authorization")
309+
authValues := make(map[string]string)
310+
for _, v := range strings.Split(authorization, ";") {
311+
splitValue := strings.Split(v, "=")
312+
authValues[splitValue[0]] = splitValue[1]
313+
}
314+
headerTimestamp := authValues["timestamp"]
315+
splitHeader := strings.Split(authorization, "signature=")
316+
shortenedHeader, signature := splitHeader[0], splitHeader[1]
317+
hostPort := strings.Split(url, "://")[1]
318+
h := hmac.New(sha256.New, signingKey(secret, headerTimestamp))
319+
input := []byte(fmt.Sprintf("POST\thttp\t%s\t%s\t\t%s\t%s",
320+
hostPort,
321+
r.URL.Path,
322+
bodyHashB64,
323+
shortenedHeader,
324+
))
325+
h.Write(input)
326+
expectedSignature := base64.StdEncoding.EncodeToString(h.Sum(nil))
327+
if signature != expectedSignature {
328+
return fmt.Errorf("Wrong signature %q in %q. Expected %q\n",
329+
signature, authorization, expectedSignature)
330+
}
331+
return nil
332+
}

akamai/cache-client_test.go

Lines changed: 1 addition & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,6 @@ package akamai
22

33
import (
44
"bytes"
5-
"crypto/hmac"
6-
"crypto/sha256"
7-
"encoding/base64"
85
"encoding/json"
96
"fmt"
107
"io/ioutil"
@@ -100,7 +97,7 @@ func (as *akamaiServer) akamaiHandler(w http.ResponseWriter, r *http.Request) {
10097
return
10198
}
10299

103-
err = as.checkSignature(r, body)
100+
err = CheckSignature("secret", as.URL, r, body)
104101
if err != nil {
105102
fmt.Printf("Error checking signature: %s\n", err)
106103
w.WriteHeader(http.StatusInternalServerError)
@@ -140,38 +137,6 @@ func (as *akamaiServer) akamaiHandler(w http.ResponseWriter, r *http.Request) {
140137
}
141138
as.sendResponse(w, resp)
142139
}
143-
144-
func (as *akamaiServer) checkSignature(r *http.Request, body []byte) error {
145-
bodyHash := sha256.Sum256(body)
146-
bodyHashB64 := base64.StdEncoding.EncodeToString(bodyHash[:])
147-
148-
authorization := r.Header.Get("Authorization")
149-
authValues := make(map[string]string)
150-
for _, v := range strings.Split(authorization, ";") {
151-
splitValue := strings.Split(v, "=")
152-
authValues[splitValue[0]] = splitValue[1]
153-
}
154-
headerTimestamp := authValues["timestamp"]
155-
splitHeader := strings.Split(authorization, "signature=")
156-
shortenedHeader, signature := splitHeader[0], splitHeader[1]
157-
hostPort := strings.Split(as.URL, "://")[1]
158-
// Assume all unittests use "secret" as the client secret.
159-
h := hmac.New(sha256.New, signingKey("secret", headerTimestamp))
160-
input := []byte(fmt.Sprintf("POST\thttp\t%s\t%s\t\t%s\t%s",
161-
hostPort,
162-
r.URL.Path,
163-
bodyHashB64,
164-
shortenedHeader,
165-
))
166-
h.Write(input)
167-
expectedSignature := base64.StdEncoding.EncodeToString(h.Sum(nil))
168-
if signature != expectedSignature {
169-
return fmt.Errorf("Wrong signature %q in %q. Expected %q\n",
170-
signature, authorization, expectedSignature)
171-
}
172-
return nil
173-
}
174-
175140
func newAkamaiServer(code int, v3 bool) *akamaiServer {
176141
m := http.NewServeMux()
177142
as := akamaiServer{

cmd/ocsp-updater/main.go

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -402,6 +402,22 @@ func (updater *OCSPUpdater) revokedCertificatesTick(ctx context.Context, batchSi
402402

403403
var allPurgeURLs []string
404404
for _, status := range statuses {
405+
// It's possible that, if our ticks are fast enough (mainly in tests), we
406+
// will get a certificate status where the ocspLastUpdated == revokedDate
407+
// and the certificate has already been revoked. In order to avoid
408+
// generating a new response and purging the existing response, quickly
409+
// check the actual response in this rare case.
410+
if status.OCSPLastUpdated.Equal(status.RevokedDate) {
411+
resp, err := ocsp.ParseResponse(status.OCSPResponse, nil)
412+
if err != nil {
413+
updater.log.AuditErrf("Failed to parse OCSP response: %s", err)
414+
return err
415+
}
416+
if resp.Status == ocsp.Revoked {
417+
// We already generated a revoked response, don't bother doing it again
418+
continue
419+
}
420+
}
405421
meta, purgeURLs, err := updater.generateRevokedResponse(ctx, status)
406422
if err != nil {
407423
updater.log.AuditErrf("Failed to generate revoked OCSP response: %s", err)
@@ -417,7 +433,7 @@ func (updater *OCSPUpdater) revokedCertificatesTick(ctx context.Context, batchSi
417433
}
418434
}
419435

420-
if updater.ccu != nil {
436+
if updater.ccu != nil && len(allPurgeURLs) > 0 {
421437
err = updater.ccu.Purge(allPurgeURLs)
422438
if err != nil {
423439
updater.log.AuditErrf("Failed to purge OCSP response from CDN: %s", err)

test/akamai-test-srv/main.go

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
package main
2+
3+
import (
4+
"encoding/json"
5+
"flag"
6+
"fmt"
7+
"io/ioutil"
8+
"log"
9+
"net/http"
10+
"sync"
11+
12+
"github.com/letsencrypt/boulder/akamai"
13+
"github.com/letsencrypt/boulder/cmd"
14+
)
15+
16+
func main() {
17+
listenAddr := flag.String("listen", "localhost:6789", "Address to listen on")
18+
secret := flag.String("secret", "", "Akamai client secret")
19+
flag.Parse()
20+
21+
// v2
22+
v2Purges := [][]string{}
23+
v3Purges := [][]string{}
24+
mu := sync.Mutex{}
25+
26+
http.HandleFunc("/debug/get-purges", func(w http.ResponseWriter, r *http.Request) {
27+
mu.Lock()
28+
defer mu.Unlock()
29+
body, err := json.Marshal(struct {
30+
V2 [][]string
31+
V3 [][]string
32+
}{V2: v2Purges, V3: v3Purges})
33+
if err != nil {
34+
w.WriteHeader(http.StatusInternalServerError)
35+
return
36+
}
37+
w.Write(body)
38+
return
39+
})
40+
41+
http.HandleFunc("/debug/reset-purges", func(w http.ResponseWriter, r *http.Request) {
42+
mu.Lock()
43+
defer mu.Unlock()
44+
v2Purges, v3Purges = [][]string{}, [][]string{}
45+
w.WriteHeader(http.StatusOK)
46+
return
47+
})
48+
49+
// Since v2 and v3 APIs share a bunch of logic just mash them into a single
50+
// handler.
51+
http.HandleFunc("/ccu/", func(w http.ResponseWriter, r *http.Request) {
52+
if r.Method != http.MethodPost {
53+
w.WriteHeader(http.StatusMethodNotAllowed)
54+
fmt.Println("Wrong method:", r.Method)
55+
return
56+
}
57+
mu.Lock()
58+
defer mu.Unlock()
59+
var purgeRequest struct {
60+
Objects []string `json:"objects"`
61+
Type string `json:"type"`
62+
Action string `json:"action"`
63+
}
64+
body, err := ioutil.ReadAll(r.Body)
65+
if err != nil {
66+
w.WriteHeader(http.StatusBadRequest)
67+
fmt.Println("Can't read body:", err)
68+
return
69+
}
70+
if err = akamai.CheckSignature(*secret, "http://"+*listenAddr, r, body); err != nil {
71+
w.WriteHeader(http.StatusUnauthorized)
72+
fmt.Println("Bad signature:", err)
73+
return
74+
}
75+
if err = json.Unmarshal(body, &purgeRequest); err != nil {
76+
w.WriteHeader(http.StatusBadRequest)
77+
fmt.Println("Can't unmarshal:", err)
78+
return
79+
}
80+
if r.URL.Path == "/ccu/v2/queues/default" {
81+
if purgeRequest.Type != "arl" || purgeRequest.Action != "remove" || len(purgeRequest.Objects) == 0 {
82+
w.WriteHeader(http.StatusBadRequest)
83+
fmt.Println("Bad parameters:", purgeRequest)
84+
return
85+
}
86+
v2Purges = append(v2Purges, purgeRequest.Objects)
87+
} else if r.URL.Path == "/ccu/v3/delete/url/staging" {
88+
if len(purgeRequest.Objects) == 0 || purgeRequest.Type != "" || purgeRequest.Action != "" {
89+
w.WriteHeader(http.StatusBadRequest)
90+
fmt.Println("Bad parameters:", purgeRequest)
91+
return
92+
}
93+
v3Purges = append(v3Purges, purgeRequest.Objects)
94+
}
95+
96+
respObj := struct {
97+
PurgeID string
98+
HTTPStatus int
99+
EstimatedSeconds int
100+
}{
101+
PurgeID: "welcome-to-the-purge",
102+
HTTPStatus: http.StatusCreated,
103+
EstimatedSeconds: 153,
104+
}
105+
w.WriteHeader(http.StatusCreated)
106+
resp, err := json.Marshal(respObj)
107+
if err != nil {
108+
return
109+
}
110+
w.Write(resp)
111+
})
112+
113+
go log.Fatal(http.ListenAndServe(*listenAddr, nil))
114+
cmd.CatchSignals(nil, nil)
115+
}

test/config-next/ocsp-updater.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,11 @@
1717
"signFailureBackoffFactor": 1.2,
1818
"signFailureBackoffMax": "30m",
1919
"debugAddr": ":8006",
20+
"akamaiBaseURL": "http://localhost:6789",
21+
"akamaiClientToken": "its-a-token",
22+
"akamaiClientSecret": "its-a-secret",
23+
"akamaiAccessToken": "idk-how-this-is-different-from-client-token-but-okay",
24+
"akamaiV3Network": "staging",
2025
"tls": {
2126
"caCertFile": "test/grpc-creds/minica.pem",
2227
"certFile": "test/grpc-creds/ocsp-updater.boulder/cert.pem",

test/config/ocsp-updater.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,10 @@
1717
"signFailureBackoffFactor": 1.2,
1818
"signFailureBackoffMax": "30m",
1919
"debugAddr": ":8006",
20+
"akamaiBaseURL": "http://localhost:6789",
21+
"akamaiClientToken": "its-a-token",
22+
"akamaiClientSecret": "its-a-secret",
23+
"akamaiAccessToken": "idk-how-this-is-different-from-client-token-but-okay",
2024
"tls": {
2125
"caCertFile": "test/grpc-creds/minica.pem",
2226
"certFile": "test/grpc-creds/ocsp-updater.boulder/cert.pem",

test/integration-test.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,24 @@ def wait_for_ocsp_good(cert_file, issuer_file, url):
164164
def wait_for_ocsp_revoked(cert_file, issuer_file, url):
165165
fetch_until(cert_file, issuer_file, url, ": good", ": revoked")
166166

167+
def reset_akamai_purges():
168+
requests.post("http://localhost:6789/debug/reset-purges")
169+
170+
def verify_akamai_purge():
171+
response = requests.get("http://localhost:6789/debug/get-purges")
172+
purgeData = response.json()
173+
if os.environ.get('BOULDER_CONFIG_DIR', '').startswith("test/config-next"):
174+
if len(purgeData["V3"]) is not 1:
175+
raise Exception("Unexpected number of Akamai v3 purges")
176+
if len(purgeData["V2"]) is not 0:
177+
raise Exception("Unexpected number of Akamai v2 purges")
178+
else:
179+
if len(purgeData["V2"]) is not 1:
180+
raise Exception("Unexpected number of Akamai v2 purges")
181+
if len(purgeData["V3"]) is not 0:
182+
raise Exception("Unexpected number of Akamai v3 purges")
183+
reset_akamai_purges()
184+
167185
def test_dns_challenge():
168186
auth_and_issue([random_domain(), random_domain()], chall_type="dns-01")
169187

@@ -297,6 +315,7 @@ def test_expiration_mailer():
297315
def test_revoke_by_account():
298316
client = chisel.make_client()
299317
cert, _ = auth_and_issue([random_domain()], client=client)
318+
reset_akamai_purges()
300319
client.revoke(cert.body, 0)
301320

302321
cert_file_pem = os.path.join(tempdir, "revokeme.pem")
@@ -305,6 +324,7 @@ def test_revoke_by_account():
305324
OpenSSL.crypto.FILETYPE_PEM, cert.body.wrapped).decode())
306325
ee_ocsp_url = "http://localhost:4002"
307326
wait_for_ocsp_revoked(cert_file_pem, "test/test-ca2.pem", ee_ocsp_url)
327+
verify_akamai_purge()
308328
return 0
309329

310330
def test_caa():
@@ -518,11 +538,13 @@ def test_admin_revoker_cert():
518538
cert, _ = auth_and_issue([random_domain()], cert_output=cert_file_pem)
519539
serial = "%x" % cert.body.get_serial_number()
520540
# Revoke certificate by serial
541+
reset_akamai_purges()
521542
run("./bin/admin-revoker serial-revoke --config %s/admin-revoker.json %s %d" % (
522543
default_config_dir, serial, 1))
523544
# Wait for OCSP response to indicate revocation took place
524545
ee_ocsp_url = "http://localhost:4002"
525546
wait_for_ocsp_revoked(cert_file_pem, "test/test-ca2.pem", ee_ocsp_url)
547+
verify_akamai_purge()
526548

527549
def test_admin_revoker_authz():
528550
# Make an authz, but don't attempt its challenges.

test/startservers.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@ def start(race_detection, fakeclock=None, account_uri=None):
9797
[8104, 'boulder-va --config %s --addr va2.boulder:9092 --debug-addr :8104' % os.path.join(default_config_dir, "va.json")],
9898
[8001, 'boulder-ca --config %s --ca-addr ca1.boulder:9093 --ocsp-addr ca1.boulder:9096 --debug-addr :8001' % os.path.join(default_config_dir, "ca-a.json")],
9999
[8101, 'boulder-ca --config %s --ca-addr ca2.boulder:9093 --ocsp-addr ca2.boulder:9096 --debug-addr :8101' % os.path.join(default_config_dir, "ca-b.json")],
100+
[6789, 'akamai-test-srv --listen localhost:6789 --secret its-a-secret'],
100101
[8006, 'ocsp-updater --config %s' % os.path.join(default_config_dir, "ocsp-updater.json")],
101102
[8002, 'boulder-ra --config %s --addr ra1.boulder:9094 --debug-addr :8002' % os.path.join(default_config_dir, "ra.json")],
102103
[8102, 'boulder-ra --config %s --addr ra2.boulder:9094 --debug-addr :8102' % os.path.join(default_config_dir, "ra.json")],

0 commit comments

Comments
 (0)