forked from TrueCloudLab/lego
357 lines
11 KiB
Go
357 lines
11 KiB
Go
|
package certificate
|
||
|
|
||
|
import (
|
||
|
"crypto"
|
||
|
"crypto/rand"
|
||
|
"crypto/rsa"
|
||
|
"encoding/json"
|
||
|
"io"
|
||
|
"net/http"
|
||
|
"testing"
|
||
|
"time"
|
||
|
|
||
|
"github.com/go-acme/lego/v4/acme"
|
||
|
"github.com/go-acme/lego/v4/acme/api"
|
||
|
"github.com/go-acme/lego/v4/certcrypto"
|
||
|
"github.com/go-acme/lego/v4/platform/tester"
|
||
|
"github.com/go-jose/go-jose/v3"
|
||
|
"github.com/stretchr/testify/assert"
|
||
|
"github.com/stretchr/testify/require"
|
||
|
)
|
||
|
|
||
|
const (
|
||
|
ariLeafPEM = `-----BEGIN CERTIFICATE-----
|
||
|
MIIDMDCCAhigAwIBAgIIPqNFaGVEHxwwDQYJKoZIhvcNAQELBQAwIDEeMBwGA1UE
|
||
|
AxMVbWluaWNhIHJvb3QgY2EgM2ExMzU2MB4XDTIyMDMxNzE3NTEwOVoXDTI0MDQx
|
||
|
NjE3NTEwOVowFjEUMBIGA1UEAxMLZXhhbXBsZS5jb20wggEiMA0GCSqGSIb3DQEB
|
||
|
AQUAA4IBDwAwggEKAoIBAQCgm9K/c+il2Pf0f8qhgxn9SKqXq88cOm9ov9AVRbPA
|
||
|
OWAAewqX2yUAwI4LZBGEgzGzTATkiXfoJ3cN3k39cH6tBbb3iSPuEn7OZpIk9D+e
|
||
|
3Q9/hX+N/jlWkaTB/FNA+7aE5IVWhmdczYilXa10V9r+RcvACJt0gsipBZVJ4jfJ
|
||
|
HnWJJGRZzzxqG/xkQmpXxZO7nOPFc8SxYKWdfcgp+rjR2ogYhSz7BfKoVakGPbpX
|
||
|
vZOuT9z4kkHra/WjwlkQhtHoTXdAxH3qC2UjMzO57Tx+otj0CxAv9O7CTJXISywB
|
||
|
vEVcmTSZkHS3eZtvvIwPx7I30ITRkYk/tLl1MbyB3SiZAgMBAAGjeDB2MA4GA1Ud
|
||
|
DwEB/wQEAwIFoDAdBgNVHSUEFjAUBggrBgEFBQcDAQYIKwYBBQUHAwIwDAYDVR0T
|
||
|
AQH/BAIwADAfBgNVHSMEGDAWgBQ4zzDRUaXHVKqlSTWkULGU4zGZpTAWBgNVHREE
|
||
|
DzANggtleGFtcGxlLmNvbTANBgkqhkiG9w0BAQsFAAOCAQEAx0aYvmCk7JYGNEXe
|
||
|
+hrOfKawkHYzWvA92cI/Oi6h+oSdHZ2UKzwFNf37cVKZ37FCrrv5pFP/xhhHvrNV
|
||
|
EnOx4IaF7OrnaTu5miZiUWuvRQP7ZGmGNFYbLTEF6/dj+WqyYdVaWzxRqHFu1ptC
|
||
|
TXysJCeyiGnR+KOOjOOQ9ZlO5JUK3OE4hagPLfaIpDDy6RXQt3ss0iNLuB1+IOtp
|
||
|
1URpvffLZQ8xPsEgOZyPWOcabTwJrtqBwily+lwPFn2mChUx846LwQfxtsXU/lJg
|
||
|
HX2RteNJx7YYNeX3Uf960mgo5an6vE8QNAsIoNHYrGyEmXDhTRe9mCHyiW2S7fZq
|
||
|
o9q12g==
|
||
|
-----END CERTIFICATE-----`
|
||
|
ariIssuerPEM = `-----BEGIN CERTIFICATE-----
|
||
|
MIIDSzCCAjOgAwIBAgIIOhNWtJ7Igr0wDQYJKoZIhvcNAQELBQAwIDEeMBwGA1UE
|
||
|
AxMVbWluaWNhIHJvb3QgY2EgM2ExMzU2MCAXDTIyMDMxNzE3NTEwOVoYDzIxMjIw
|
||
|
MzE3MTc1MTA5WjAgMR4wHAYDVQQDExVtaW5pY2Egcm9vdCBjYSAzYTEzNTYwggEi
|
||
|
MA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDc3P6cxcCZ7FQOQrYuigReSa8T
|
||
|
IOPNKmlmX9OrTkPwjThiMNEETYKO1ea99yXPK36LUHC6OLmZ9jVQW2Ny1qwQCOy6
|
||
|
TrquhnwKgtkBMDAZBLySSEXYdKL3r0jA4sflW130/OLwhstU/yv0J8+pj7eSVOR3
|
||
|
zJBnYd1AqnXHRSwQm299KXgqema7uwsa8cgjrXsBzAhrwrvYlVhpWFSv3lQRDFQg
|
||
|
c5Z/ZDV9i26qiaJsCCmdisJZWN7N2luUgxdRqzZ4Cr2Xoilg3T+hkb2y/d6ttsPA
|
||
|
kaSA+pq3q6Qa7/qfGdT5WuUkcHpvKNRWqnwT9rCYlmG00r3hGgc42D/z1VvfAgMB
|
||
|
AAGjgYYwgYMwDgYDVR0PAQH/BAQDAgKEMB0GA1UdJQQWMBQGCCsGAQUFBwMBBggr
|
||
|
BgEFBQcDAjASBgNVHRMBAf8ECDAGAQH/AgEAMB0GA1UdDgQWBBQ4zzDRUaXHVKql
|
||
|
STWkULGU4zGZpTAfBgNVHSMEGDAWgBQ4zzDRUaXHVKqlSTWkULGU4zGZpTANBgkq
|
||
|
hkiG9w0BAQsFAAOCAQEArbDHhEjGedjb/YjU80aFTPWOMRjgyfQaPPgyxwX6Dsid
|
||
|
1i2H1x4ud4ntz3sTZZxdQIrOqtlIWTWVCjpStwGxaC+38SdreiTTwy/nikXGa/6W
|
||
|
ZyQRppR3agh/pl5LHVO6GsJz3YHa7wQhEhj3xsRwa9VrRXgHbLGbPOFVRTHPjaPg
|
||
|
Gtsv2PN3f67DsPHF47ASqyOIRpLZPQmZIw6D3isJwfl+8CzvlB1veO0Q3uh08IJc
|
||
|
fspYQXvFBzYa64uKxNAJMi4Pby8cf4r36Wnb7cL4ho3fOHgAltxdW8jgibRzqZpQ
|
||
|
QKyxn2jX7kxeUDt0hFDJE8lOrhP73m66eBNzxe//FQ==
|
||
|
-----END CERTIFICATE-----`
|
||
|
ariLeafCertID = "MFswCwYJYIZIAWUDBAIBBCCeWLRusNLb--vmWOkxm34qDjTMWkc3utIhOMoMwKDqbgQg2iiKWySZrD-6c88HMZ6vhIHZPamChLlzGHeZ7pTS8jYCCD6jRWhlRB8c"
|
||
|
)
|
||
|
|
||
|
func Test_makeCertID(t *testing.T) {
|
||
|
leaf, err := certcrypto.ParsePEMCertificate([]byte(ariLeafPEM))
|
||
|
require.NoError(t, err)
|
||
|
issuer, err := certcrypto.ParsePEMCertificate([]byte(ariIssuerPEM))
|
||
|
require.NoError(t, err)
|
||
|
|
||
|
actual, err := makeCertID(leaf, issuer, crypto.SHA256.String())
|
||
|
require.NoError(t, err)
|
||
|
assert.Equal(t, ariLeafCertID, actual)
|
||
|
}
|
||
|
|
||
|
func TestCertifier_GetRenewalInfo(t *testing.T) {
|
||
|
leaf, err := certcrypto.ParsePEMCertificate([]byte(ariLeafPEM))
|
||
|
require.NoError(t, err)
|
||
|
issuer, err := certcrypto.ParsePEMCertificate([]byte(ariIssuerPEM))
|
||
|
require.NoError(t, err)
|
||
|
|
||
|
// Test with a fake API.
|
||
|
mux, apiURL := tester.SetupFakeAPI(t)
|
||
|
mux.HandleFunc("/renewalInfo/"+ariLeafCertID, func(w http.ResponseWriter, r *http.Request) {
|
||
|
if r.Method != http.MethodGet {
|
||
|
http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed)
|
||
|
return
|
||
|
}
|
||
|
|
||
|
w.Header().Set("Content-Type", "application/json")
|
||
|
w.WriteHeader(http.StatusOK)
|
||
|
_, wErr := w.Write([]byte(`{
|
||
|
"suggestedWindow": {
|
||
|
"start": "2020-03-17T17:51:09Z",
|
||
|
"end": "2020-03-17T18:21:09Z"
|
||
|
},
|
||
|
"explanationUrl": "https://aricapable.ca/docs/renewal-advice/"
|
||
|
}
|
||
|
}`))
|
||
|
require.NoError(t, wErr)
|
||
|
})
|
||
|
|
||
|
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
||
|
require.NoError(t, err, "Could not generate test key")
|
||
|
|
||
|
core, err := api.New(http.DefaultClient, "lego-test", apiURL+"/dir", "", key)
|
||
|
require.NoError(t, err)
|
||
|
|
||
|
certifier := NewCertifier(core, &resolverMock{}, CertifierOptions{KeyType: certcrypto.RSA2048})
|
||
|
|
||
|
ri, err := certifier.GetRenewalInfo(RenewalInfoRequest{leaf, issuer, crypto.SHA256.String()})
|
||
|
require.NoError(t, err)
|
||
|
require.NotNil(t, ri)
|
||
|
assert.Equal(t, "2020-03-17T17:51:09Z", ri.SuggestedWindow.Start.Format(time.RFC3339))
|
||
|
assert.Equal(t, "2020-03-17T18:21:09Z", ri.SuggestedWindow.End.Format(time.RFC3339))
|
||
|
assert.Equal(t, "https://aricapable.ca/docs/renewal-advice/", ri.ExplanationURL)
|
||
|
}
|
||
|
|
||
|
func TestCertifier_GetRenewalInfo_errors(t *testing.T) {
|
||
|
leaf, err := certcrypto.ParsePEMCertificate([]byte(ariLeafPEM))
|
||
|
require.NoError(t, err)
|
||
|
issuer, err := certcrypto.ParsePEMCertificate([]byte(ariIssuerPEM))
|
||
|
require.NoError(t, err)
|
||
|
|
||
|
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
||
|
require.NoError(t, err, "Could not generate test key")
|
||
|
|
||
|
testCases := []struct {
|
||
|
desc string
|
||
|
httpClient *http.Client
|
||
|
request RenewalInfoRequest
|
||
|
handler http.HandlerFunc
|
||
|
}{
|
||
|
{
|
||
|
desc: "API timeout",
|
||
|
httpClient: &http.Client{Timeout: 500 * time.Millisecond}, // HTTP client that times out after 500ms.
|
||
|
request: RenewalInfoRequest{leaf, issuer, crypto.SHA256.String()},
|
||
|
handler: func(w http.ResponseWriter, r *http.Request) {
|
||
|
// API that takes 2ms to respond.
|
||
|
time.Sleep(2 * time.Millisecond)
|
||
|
},
|
||
|
},
|
||
|
{
|
||
|
desc: "API error",
|
||
|
httpClient: http.DefaultClient,
|
||
|
request: RenewalInfoRequest{leaf, issuer, crypto.SHA256.String()},
|
||
|
handler: func(w http.ResponseWriter, r *http.Request) {
|
||
|
// API that responds with error instead of renewal info.
|
||
|
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
|
||
|
},
|
||
|
},
|
||
|
{
|
||
|
desc: "Issuer certificate is nil",
|
||
|
httpClient: http.DefaultClient,
|
||
|
request: RenewalInfoRequest{leaf, nil, crypto.SHA256.String()},
|
||
|
handler: func(w http.ResponseWriter, r *http.Request) {
|
||
|
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
|
||
|
},
|
||
|
},
|
||
|
}
|
||
|
|
||
|
for _, test := range testCases {
|
||
|
test := test
|
||
|
t.Run(test.desc, func(t *testing.T) {
|
||
|
t.Parallel()
|
||
|
|
||
|
mux, apiURL := tester.SetupFakeAPI(t)
|
||
|
mux.HandleFunc("/renewalInfo/"+ariLeafCertID, test.handler)
|
||
|
|
||
|
core, err := api.New(test.httpClient, "lego-test", apiURL+"/dir", "", key)
|
||
|
require.NoError(t, err)
|
||
|
|
||
|
certifier := NewCertifier(core, &resolverMock{}, CertifierOptions{KeyType: certcrypto.RSA2048})
|
||
|
|
||
|
response, err := certifier.GetRenewalInfo(test.request)
|
||
|
require.Error(t, err)
|
||
|
assert.Nil(t, response)
|
||
|
})
|
||
|
}
|
||
|
}
|
||
|
|
||
|
func TestCertifier_UpdateRenewalInfo(t *testing.T) {
|
||
|
leaf, err := certcrypto.ParsePEMCertificate([]byte(ariLeafPEM))
|
||
|
require.NoError(t, err)
|
||
|
issuer, err := certcrypto.ParsePEMCertificate([]byte(ariIssuerPEM))
|
||
|
require.NoError(t, err)
|
||
|
|
||
|
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
||
|
require.NoError(t, err, "Could not generate test key")
|
||
|
|
||
|
// Test with a fake API.
|
||
|
mux, apiURL := tester.SetupFakeAPI(t)
|
||
|
mux.HandleFunc("/renewalInfo", func(w http.ResponseWriter, r *http.Request) {
|
||
|
if r.Method != http.MethodPost {
|
||
|
http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed)
|
||
|
return
|
||
|
}
|
||
|
|
||
|
body, rsbErr := readSignedBody(r, key)
|
||
|
if rsbErr != nil {
|
||
|
http.Error(w, rsbErr.Error(), http.StatusBadRequest)
|
||
|
return
|
||
|
}
|
||
|
|
||
|
var req acme.RenewalInfoUpdateRequest
|
||
|
err = json.Unmarshal(body, &req)
|
||
|
assert.NoError(t, err)
|
||
|
assert.True(t, req.Replaced)
|
||
|
assert.Equal(t, ariLeafCertID, req.CertID)
|
||
|
|
||
|
w.WriteHeader(http.StatusOK)
|
||
|
})
|
||
|
|
||
|
core, err := api.New(http.DefaultClient, "lego-test", apiURL+"/dir", "", key)
|
||
|
require.NoError(t, err)
|
||
|
|
||
|
certifier := NewCertifier(core, &resolverMock{}, CertifierOptions{KeyType: certcrypto.RSA2048})
|
||
|
|
||
|
err = certifier.UpdateRenewalInfo(RenewalInfoRequest{leaf, issuer, crypto.SHA256.String()})
|
||
|
require.NoError(t, err)
|
||
|
}
|
||
|
|
||
|
func TestCertifier_UpdateRenewalInfo_errors(t *testing.T) {
|
||
|
leaf, err := certcrypto.ParsePEMCertificate([]byte(ariLeafPEM))
|
||
|
require.NoError(t, err)
|
||
|
issuer, err := certcrypto.ParsePEMCertificate([]byte(ariIssuerPEM))
|
||
|
require.NoError(t, err)
|
||
|
|
||
|
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
||
|
require.NoError(t, err, "Could not generate test key")
|
||
|
|
||
|
testCases := []struct {
|
||
|
desc string
|
||
|
request RenewalInfoRequest
|
||
|
}{
|
||
|
{
|
||
|
desc: "API error",
|
||
|
request: RenewalInfoRequest{leaf, issuer, crypto.SHA256.String()},
|
||
|
},
|
||
|
{
|
||
|
desc: "Certificate is nil",
|
||
|
request: RenewalInfoRequest{nil, issuer, crypto.SHA256.String()},
|
||
|
},
|
||
|
}
|
||
|
|
||
|
for _, test := range testCases {
|
||
|
test := test
|
||
|
t.Run(test.desc, func(t *testing.T) {
|
||
|
t.Parallel()
|
||
|
|
||
|
mux, apiURL := tester.SetupFakeAPI(t)
|
||
|
|
||
|
// Always returns an error.
|
||
|
mux.HandleFunc("/renewalInfo", func(w http.ResponseWriter, r *http.Request) {
|
||
|
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
|
||
|
})
|
||
|
|
||
|
core, err := api.New(http.DefaultClient, "lego-test", apiURL+"/dir", "", key)
|
||
|
require.NoError(t, err)
|
||
|
|
||
|
certifier := NewCertifier(core, &resolverMock{}, CertifierOptions{KeyType: certcrypto.RSA2048})
|
||
|
|
||
|
err = certifier.UpdateRenewalInfo(test.request)
|
||
|
require.Error(t, err)
|
||
|
})
|
||
|
}
|
||
|
}
|
||
|
|
||
|
func TestRenewalInfoResponse_ShouldRenew(t *testing.T) {
|
||
|
now := time.Now().UTC()
|
||
|
|
||
|
t.Run("Window is in the past", func(t *testing.T) {
|
||
|
ri := RenewalInfoResponse{
|
||
|
acme.RenewalInfoResponse{
|
||
|
SuggestedWindow: acme.Window{
|
||
|
Start: now.Add(-2 * time.Hour),
|
||
|
End: now.Add(-1 * time.Hour),
|
||
|
},
|
||
|
ExplanationURL: "",
|
||
|
},
|
||
|
}
|
||
|
|
||
|
rt := ri.ShouldRenewAt(now, 0)
|
||
|
require.NotNil(t, rt)
|
||
|
assert.Equal(t, now, *rt)
|
||
|
})
|
||
|
|
||
|
t.Run("Window is in the future", func(t *testing.T) {
|
||
|
ri := RenewalInfoResponse{
|
||
|
acme.RenewalInfoResponse{
|
||
|
SuggestedWindow: acme.Window{
|
||
|
Start: now.Add(1 * time.Hour),
|
||
|
End: now.Add(2 * time.Hour),
|
||
|
},
|
||
|
ExplanationURL: "",
|
||
|
},
|
||
|
}
|
||
|
|
||
|
rt := ri.ShouldRenewAt(now, 0)
|
||
|
assert.Nil(t, rt)
|
||
|
})
|
||
|
|
||
|
t.Run("Window is in the future, but caller is willing to sleep", func(t *testing.T) {
|
||
|
ri := RenewalInfoResponse{
|
||
|
acme.RenewalInfoResponse{
|
||
|
SuggestedWindow: acme.Window{
|
||
|
Start: now.Add(1 * time.Hour),
|
||
|
End: now.Add(2 * time.Hour),
|
||
|
},
|
||
|
ExplanationURL: "",
|
||
|
},
|
||
|
}
|
||
|
|
||
|
rt := ri.ShouldRenewAt(now, 2*time.Hour)
|
||
|
require.NotNil(t, rt)
|
||
|
assert.True(t, rt.Before(now.Add(2*time.Hour)))
|
||
|
})
|
||
|
|
||
|
t.Run("Window is in the future, but caller isn't willing to sleep long enough", func(t *testing.T) {
|
||
|
ri := RenewalInfoResponse{
|
||
|
acme.RenewalInfoResponse{
|
||
|
SuggestedWindow: acme.Window{
|
||
|
Start: now.Add(1 * time.Hour),
|
||
|
End: now.Add(2 * time.Hour),
|
||
|
},
|
||
|
ExplanationURL: "",
|
||
|
},
|
||
|
}
|
||
|
|
||
|
rt := ri.ShouldRenewAt(now, 59*time.Minute)
|
||
|
assert.Nil(t, rt)
|
||
|
})
|
||
|
}
|
||
|
|
||
|
func readSignedBody(r *http.Request, privateKey *rsa.PrivateKey) ([]byte, error) {
|
||
|
reqBody, err := io.ReadAll(r.Body)
|
||
|
if err != nil {
|
||
|
return nil, err
|
||
|
}
|
||
|
|
||
|
jws, err := jose.ParseSigned(string(reqBody))
|
||
|
if err != nil {
|
||
|
return nil, err
|
||
|
}
|
||
|
|
||
|
body, err := jws.Verify(&jose.JSONWebKey{
|
||
|
Key: privateKey.Public(),
|
||
|
Algorithm: "RSA",
|
||
|
})
|
||
|
if err != nil {
|
||
|
return nil, err
|
||
|
}
|
||
|
|
||
|
return body, nil
|
||
|
}
|