From f681f5a6b12af15af15052d251b0da576c7e6b84 Mon Sep 17 00:00:00 2001 From: Tom Wieczorek Date: Wed, 21 Feb 2018 10:49:44 +0100 Subject: [PATCH] Add ACME DNS-01 provider for Akamai FastDNS --- docs/api-types/issuer/spec.md | 16 ++ pkg/apis/certmanager/v1alpha1/types.go | 10 + .../v1alpha1/zz_generated.deepcopy.go | 28 ++ pkg/issuer/acme/dns/akamai/akamai.go | 272 ++++++++++++++++++ pkg/issuer/acme/dns/akamai/akamai_test.go | 163 +++++++++++ pkg/issuer/acme/dns/akamai/edgegridauth.go | 264 +++++++++++++++++ .../acme/dns/akamai/edgegridauth_test.go | 81 ++++++ pkg/issuer/acme/dns/dns.go | 41 +++ 8 files changed, 875 insertions(+) create mode 100644 pkg/issuer/acme/dns/akamai/akamai.go create mode 100644 pkg/issuer/acme/dns/akamai/akamai_test.go create mode 100644 pkg/issuer/acme/dns/akamai/edgegridauth.go create mode 100644 pkg/issuer/acme/dns/akamai/edgegridauth_test.go diff --git a/docs/api-types/issuer/spec.md b/docs/api-types/issuer/spec.md index d60b905e7..b7dcdcad5 100644 --- a/docs/api-types/issuer/spec.md +++ b/docs/api-types/issuer/spec.md @@ -142,6 +142,22 @@ cloudflare: key: api-key ``` +##### Akamai FastDNS + +```yaml +akamai: + serviceConsumerDomain: akab-tho6xie2aiteip8p-poith5aej0ughaba.luna.akamaiapis.net + clientTokenSecretRef: + name: akamai-dns + key: clientToken + clientSecretSecretRef: + name: akamai-dns + key: clientSecret + accessTokenSecretRef: + name: akamai-dns + key: accessToken +``` + ## CA Configuration CA Issuers issue certificates signed from a X509 signing keypair, stored in a diff --git a/pkg/apis/certmanager/v1alpha1/types.go b/pkg/apis/certmanager/v1alpha1/types.go index 7374bf980..2906ba693 100644 --- a/pkg/apis/certmanager/v1alpha1/types.go +++ b/pkg/apis/certmanager/v1alpha1/types.go @@ -111,12 +111,22 @@ type ACMEIssuerDNS01Config struct { type ACMEIssuerDNS01Provider struct { Name string `json:"name"` + Akamai *ACMEIssuerDNS01ProviderAkamai `json:"akamai,omitempty"` CloudDNS *ACMEIssuerDNS01ProviderCloudDNS `json:"clouddns,omitempty"` Cloudflare *ACMEIssuerDNS01ProviderCloudflare `json:"cloudflare,omitempty"` Route53 *ACMEIssuerDNS01ProviderRoute53 `json:"route53,omitempty"` AzureDNS *ACMEIssuerDNS01ProviderAzureDNS `json:"azuredns,omitempty"` } +// ACMEIssuerDNS01ProviderAkamai is a structure containing the DNS +// configuration for Akamai DNS—Zone Record Management API +type ACMEIssuerDNS01ProviderAkamai struct { + ServiceConsumerDomain string `json:"serviceConsumerDomain"` + ClientToken SecretKeySelector `json:"clientTokenSecretRef"` + ClientSecret SecretKeySelector `json:"clientSecretSecretRef"` + AccessToken SecretKeySelector `json:"accessTokenSecretRef"` +} + // ACMEIssuerDNS01ProviderCloudDNS is a structure containing the DNS // configuration for Google Cloud DNS type ACMEIssuerDNS01ProviderCloudDNS struct { diff --git a/pkg/apis/certmanager/v1alpha1/zz_generated.deepcopy.go b/pkg/apis/certmanager/v1alpha1/zz_generated.deepcopy.go index 54a844bd2..bcd186852 100644 --- a/pkg/apis/certmanager/v1alpha1/zz_generated.deepcopy.go +++ b/pkg/apis/certmanager/v1alpha1/zz_generated.deepcopy.go @@ -204,6 +204,15 @@ func (in *ACMEIssuerDNS01Config) DeepCopy() *ACMEIssuerDNS01Config { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ACMEIssuerDNS01Provider) DeepCopyInto(out *ACMEIssuerDNS01Provider) { *out = *in + if in.Akamai != nil { + in, out := &in.Akamai, &out.Akamai + if *in == nil { + *out = nil + } else { + *out = new(ACMEIssuerDNS01ProviderAkamai) + **out = **in + } + } if in.CloudDNS != nil { in, out := &in.CloudDNS, &out.CloudDNS if *in == nil { @@ -253,6 +262,25 @@ func (in *ACMEIssuerDNS01Provider) DeepCopy() *ACMEIssuerDNS01Provider { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ACMEIssuerDNS01ProviderAkamai) DeepCopyInto(out *ACMEIssuerDNS01ProviderAkamai) { + *out = *in + out.ClientToken = in.ClientToken + out.ClientSecret = in.ClientSecret + out.AccessToken = in.AccessToken + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ACMEIssuerDNS01ProviderAkamai. +func (in *ACMEIssuerDNS01ProviderAkamai) DeepCopy() *ACMEIssuerDNS01ProviderAkamai { + if in == nil { + return nil + } + out := new(ACMEIssuerDNS01ProviderAkamai) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ACMEIssuerDNS01ProviderAzureDNS) DeepCopyInto(out *ACMEIssuerDNS01ProviderAzureDNS) { *out = *in diff --git a/pkg/issuer/acme/dns/akamai/akamai.go b/pkg/issuer/acme/dns/akamai/akamai.go new file mode 100644 index 000000000..84d3f8d97 --- /dev/null +++ b/pkg/issuer/acme/dns/akamai/akamai.go @@ -0,0 +1,272 @@ +// Package akamai implements a DNS provider for solving the DNS-01 +// challenge using Akamai FastDNS. +// See https://developer.akamai.com/api/luna/config-dns/overview.html +package akamai + +import ( + "bytes" + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + "strings" + "time" + + "github.com/golang/glog" + + "github.com/jetstack/cert-manager/pkg/issuer/acme/dns/util" + "github.com/pkg/errors" +) + +// DNSProvider is an implementation of the acme.ChallengeProvider interface +type DNSProvider struct { + // serviceConsumerDomain as issued by Akamai Luna Control Center. + // The ServiceConsumerDomain is the base URL. + serviceConsumerDomain string + + auth *EdgeGridAuth + + transport http.RoundTripper + findHostedDomainByFqdn func(string) (string, error) +} + +// NewDNSProvider returns a DNSProvider instance configured for Akamai. +func NewDNSProvider(serviceConsumerDomain, clientToken, clientSecret, accessToken string) (*DNSProvider, error) { + return &DNSProvider{ + serviceConsumerDomain, + NewEdgeGridAuth(clientToken, clientSecret, accessToken), + http.DefaultTransport, + findHostedDomainByFqdn, + }, nil +} + +func findHostedDomainByFqdn(fqdn string) (string, error) { + zone, err := util.FindZoneByFqdn(fqdn, util.RecursiveNameservers) + if err != nil { + return "", err + } + + return util.UnFqdn(zone), nil +} + +// Timeout returns the timeout and interval to use when checking for DNS +// propagation. Adjusting here to cope with spikes in propagation times. +func (a *DNSProvider) Timeout() (timeout, interval time.Duration) { + return 5 * time.Minute, 5 * time.Second +} + +// Present creates a TXT record to fulfil the dns-01 challenge +func (a *DNSProvider) Present(domain, token, keyAuth string) error { + fqdn, value, ttl := util.DNS01Record(domain, keyAuth) + return a.setTxtRecord(fqdn, &dns01Record{value, ttl}) +} + +// CleanUp removes the TXT record matching the specified parameters +func (a *DNSProvider) CleanUp(domain, token, keyAuth string) error { + fqdn, _, _ := util.DNS01Record(domain, keyAuth) + return a.setTxtRecord(fqdn, nil) +} + +type dns01Record struct { + value string + ttl int +} + +func (a *DNSProvider) setTxtRecord(fqdn string, dns01Record *dns01Record) error { + hostedDomain, err := a.findHostedDomainByFqdn(fqdn) + if err != nil { + return errors.Wrapf(err, "failed to determine hosted domain for %q", fqdn) + } + + zoneData, err := a.loadZoneData(hostedDomain) + if err != nil { + return errors.Wrapf(err, "failed to load zone data for %q", hostedDomain) + } + + recordName, err := makeTxtRecordName(fqdn, hostedDomain) + if err != nil { + return errors.Wrapf(err, "failed to create TXT record name") + } + + if updated, err := zoneData.setTxtRecord(recordName, dns01Record); !updated || err != nil { + if err != nil { + return errors.Wrapf(err, "failed to set TXT record in %q", hostedDomain) + } + + return errors.Errorf("no %q TXT record found in %q", recordName, hostedDomain) + } + + newSerial, err := zoneData.incSoaSerial() + if err != nil { + return errors.Wrapf(err, "failed to increment SOA serial for %q", hostedDomain) + } + + if err := a.saveZoneData(hostedDomain, zoneData); err != nil { + return errors.Wrapf(err, "failed to save zone data for %q", hostedDomain) + } + + glog.V(4).Infof("Updated Akamai TXT record for %q on %q using SOA serial of %d", recordName, hostedDomain, newSerial) + + return nil +} + +func makeTxtRecordName(fqdn, hostedDomain string) (string, error) { + if !strings.HasSuffix(fqdn, "."+hostedDomain+".") { + return "", errors.Errorf("fqdn %q is not part of %q", fqdn, hostedDomain) + } + + return fqdn[0 : len(fqdn)-len(hostedDomain)-2], nil +} + +func (a *DNSProvider) urlForDomain(domain string) string { + return fmt.Sprintf("https://%s/config-dns/v1/zones/%s", a.serviceConsumerDomain, domain) +} + +func (a *DNSProvider) loadZoneData(domain string) (zoneData, error) { + url := a.urlForDomain(domain) + req, err := http.NewRequest(http.MethodGet, url, http.NoBody) + if err != nil { + return nil, errors.Wrap(err, "failed to create HTTP request") + } + + responsePayload, err := a.makeRequest(req) + if err != nil { + return nil, err + } + + var zoneData map[string]interface{} + err = json.NewDecoder(bytes.NewReader(responsePayload)).Decode(&zoneData) + if err != nil { + return nil, errors.Wrap(err, "failed to decode Akamai OPEN API response") + } + + return zoneData, nil +} + +func (a *DNSProvider) saveZoneData(domain string, data zoneData) error { + body, err := json.Marshal(data) + if err != nil { + return errors.Wrap(err, "failed to encode zone data") + } + + url := a.urlForDomain(domain) + req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(body)) + if err != nil { + return errors.Wrap(err, "failed to create HTTP request") + } + + req.Header.Set("Content-Type", "application/json") + + if _, err := a.makeRequest(req); err != nil { + return err + } + + return nil +} + +func (a *DNSProvider) makeRequest(req *http.Request) ([]byte, error) { + if err := a.auth.SignRequest(req); err != nil { + return nil, errors.Wrap(err, "failed to sign HTTP request") + } + + client := http.Client{ + Transport: a.transport, + Timeout: 30 * time.Second, + } + + resp, err := client.Do(req) + if err != nil { + return nil, errors.Wrap(err, "error querying Akamai OPEN API") + } + + defer resp.Body.Close() + + if resp.StatusCode == http.StatusNoContent { + return nil, nil + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("Akamai OPEN API returned %d %s", resp.StatusCode, resp.Status) + } + + responsePayload, err := ioutil.ReadAll(resp.Body) + if err != nil { + return nil, errors.Wrap(err, "failed to read response payload") + } + + return responsePayload, nil +} + +type zoneData map[string]interface{} + +func (z zoneData) setTxtRecord(name string, dns01Record *dns01Record) (bool, error) { + zone, ok := z["zone"].(map[string]interface{}) + if !ok { + return false, errors.New("failed to retrieve zone from zone data") + } + + var txtRecords []interface{} + if txtNode, ok := zone["txt"]; ok { + if txtRecords, ok = txtNode.([]interface{}); !ok { + return false, errors.New("failed to retrieve TXT records from zone data") + } + } + + if dns01Record == nil { + if txtRecords = deleteRecord(txtRecords, name); txtRecords == nil { + return false, nil + } + } else { + txtRecords = updateRecord(txtRecords, name, map[string]interface{}{ + "name": name, + "ttl": dns01Record.ttl, + "active": true, + "target": dns01Record.value, + }) + } + + if len(txtRecords) < 1 { + delete(zone, "txt") + } else { + zone["txt"] = txtRecords + } + + return true, nil +} + +func (z zoneData) incSoaSerial() (uint64, error) { + soa, ok := z["zone"].(map[string]interface{})["soa"].(map[string]interface{}) + if !ok { + return 0, errors.New("failed to retrieve SOA record from zone data") + } + + serial, ok := soa["serial"].(float64) + if !ok { + return 0, errors.New("failed to retrieve SOA serial from zone data") + } + + newSerial := uint64(serial) + 1 + soa["serial"] = newSerial + return newSerial, nil +} + +func deleteRecord(records []interface{}, name string) []interface{} { + for pos := range records { + if recordName, ok := records[pos].(map[string]interface{})["name"]; ok && recordName == name { + return append(records[:pos], records[pos+1:]...) + } + } + + return nil +} + +func updateRecord(records []interface{}, name string, record map[string]interface{}) []interface{} { + for pos := range records { + if records[pos].(map[string]interface{})["name"] == name { + records[pos] = record + return records + } + } + + return append(records, record) +} diff --git a/pkg/issuer/acme/dns/akamai/akamai_test.go b/pkg/issuer/acme/dns/akamai/akamai_test.go new file mode 100644 index 000000000..a787d1398 --- /dev/null +++ b/pkg/issuer/acme/dns/akamai/akamai_test.go @@ -0,0 +1,163 @@ +package akamai + +import ( + "bytes" + "encoding/json" + "io/ioutil" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +const sampleZoneData = `{ + "token": "a184671d5307a388180fbf7f11dbdf46", + "zone": { + "name": "example.com", + "soa": { + "contact": "hostmaster.akamai.com.", + "expire": 604800, + "minimum": 180, + "originserver": "use4.akamai.com.", + "refresh": 900, + "retry": 300, + "serial": 1271354824, + "ttl": 900 + }, + "ns": [ + { + "active": true, + "name": "", + "target": "use4.akam.net.", + "ttl": 3600 + }, + { + "active": true, + "name": "", + "target": "use3.akam.net.", + "ttl": 3600 + } + ] + } +}` + +const sampleZoneDataWithTxt = `{ + "token": "a184671d5307a388180fbf7f11dbdf46", + "zone": { + "name": "example.com", + "soa": { + "contact": "hostmaster.akamai.com.", + "expire": 604800, + "minimum": 180, + "originserver": "use4.akamai.com.", + "refresh": 900, + "retry": 300, + "serial": 1271354825, + "ttl": 900 + }, + "ns": [ + { + "active": true, + "name": "", + "target": "use4.akam.net.", + "ttl": 3600 + }, + { + "active": true, + "name": "", + "target": "use3.akam.net.", + "ttl": 3600 + } + ], + "txt": [ + { + "active": true, + "name" :"_acme-challenge.test", + "target": "dns01-key", + "ttl": 60 + } + ] + } +}` + +type httpResponder func(req *http.Request) (*http.Response, error) + +func (r httpResponder) RoundTrip(req *http.Request) (*http.Response, error) { + return r(req) +} + +func TestPresent(t *testing.T) { + akamai, err := NewDNSProvider("akamai.example.com", "token", "secret", "access-token") + assert.NoError(t, err) + + var response []byte + mockTransport(t, akamai, "example.com", sampleZoneData, &response) + + assert.NoError(t, akamai.Present("test.example.com", "dns01-token", "dns01-key")) + + var expected, actual map[string]interface{} + assert.NoError(t, json.Unmarshal([]byte(sampleZoneDataWithTxt), &expected)) + assert.NoError(t, json.Unmarshal(response, &actual)) + assert.EqualValues(t, expected, actual) +} + +func TestCleanUp(t *testing.T) { + akamai, err := NewDNSProvider("akamai.example.com", "token", "secret", "access-token") + assert.NoError(t, err) + + var response []byte + mockTransport(t, akamai, "example.com", sampleZoneDataWithTxt, &response) + + assert.NoError(t, akamai.CleanUp("test.example.com", "dns01-token", "dns01-key")) + + var expected, actual map[string]interface{} + assert.NoError(t, json.Unmarshal([]byte(sampleZoneData), &expected)) + expected["zone"].(map[string]interface{})["soa"].(map[string]interface{})["serial"] = 1271354826. + assert.NoError(t, json.Unmarshal(response, &actual)) + assert.EqualValues(t, expected, actual) +} + +func mockTransport(t *testing.T, akamai *DNSProvider, domain, data string, response *[]byte) { + akamai.transport = httpResponder(func(req *http.Request) (*http.Response, error) { + defer req.Body.Close() + + if req.URL.String() != "https://akamai.example.com/config-dns/v1/zones/"+domain { + return &http.Response{ + StatusCode: http.StatusNotFound, + Body: http.NoBody, + }, nil + } + + if req.Method == http.MethodGet { + return &http.Response{ + StatusCode: http.StatusOK, + Body: ioutil.NopCloser(bytes.NewReader([]byte(data))), + }, nil + } + + if req.Method == http.MethodPost { + if req.Header.Get("Content-Type") != "application/json" { + t.Fatalf("usupported Content Type: %v", req.Header.Get("Content-Type")) + } + + var err error + *response, err = ioutil.ReadAll(req.Body) + assert.NoError(t, err) + + return &http.Response{ + StatusCode: http.StatusNoContent, + Body: http.NoBody, + }, nil + } + + t.Fatalf("unexpected method: %v", req.Method) + return nil, nil + }) + akamai.findHostedDomainByFqdn = func(fqdn string) (string, error) { + if !strings.HasSuffix(fqdn, domain+".") { + t.Fatalf("unexpected fqdn: %s", fqdn) + } + return domain, nil + } +} diff --git a/pkg/issuer/acme/dns/akamai/edgegridauth.go b/pkg/issuer/acme/dns/akamai/edgegridauth.go new file mode 100644 index 000000000..0e3d3a31e --- /dev/null +++ b/pkg/issuer/acme/dns/akamai/edgegridauth.go @@ -0,0 +1,264 @@ +package akamai + +import ( + "bytes" + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "fmt" + "io/ioutil" + "net/http" + "net/url" + "sort" + "strings" + "time" + "unicode" +) + +// EdgeGridAuth holds all values required to perform Akamai API Client Authentication. +// See https://developer.akamai.com/introduction/Client_Auth.html. +type EdgeGridAuth struct { + ClientToken string + ClientSecret string + AccessToken string + HeadersToSign []string + MaxBody int + + now func() time.Time + createNonce func() (string, error) +} + +type signingData struct { + timestamp string + authHeader string + dataToSign string +} + +// edgeGridAuthTimeFormat is used for timestamps in request signatures. +const edgeGridAuthTimeFormat = "20060102T15:04:05-0700" // yyyyMMddTHH:mm:ss+0000 + +const NoMaxBody = -1 + +// NewEdgeGridAuth returns a new request signer for Akamai EdgeGrid +func NewEdgeGridAuth(clientToken, clientSecret, accessToken string, headersToSign ...string) *EdgeGridAuth { + return &EdgeGridAuth{ + ClientToken: clientToken, + ClientSecret: clientSecret, + AccessToken: accessToken, + HeadersToSign: headersToSign, + MaxBody: NoMaxBody, + + now: time.Now, + createNonce: createRandomNonce, + } +} + +// SignRequest calculates the signature for Akamai Open API and adds it as the Authorization header. +// The Authorization header starts with the signing algorithm moniker (name of the algorithm) used to sign the request. +// The moniker below identifies EdgeGrid V1, hash message authentication code, SHA–256 as the hash standard. +// This moniker is then followed by a space and an ordered list of name value pairs with each field separated by a semicolon. +func (e *EdgeGridAuth) SignRequest(req *http.Request) error { + signingData, err := e.signingData(req) + if err != nil { + return err + } + + req.Header.Set("Authorization", fmt.Sprintf( + "%ssignature=%s", + signingData.authHeader, + e.calculateRequestSignature(signingData))) + + return nil +} + +func (e *EdgeGridAuth) calculateRequestSignature(signingData *signingData) string { + return computeSignature( + signingData.dataToSign, + e.signingKey(signingData.timestamp)) +} + +func (e *EdgeGridAuth) signingData(req *http.Request) (*signingData, error) { + nonce, err := e.createNonce() + if err != nil { + return nil, err + } + + timestamp := e.now().UTC().Format(edgeGridAuthTimeFormat) + authHeader := fmt.Sprintf("EG1-HMAC-SHA256 client_token=%s;access_token=%s;timestamp=%s;nonce=%s;", + e.ClientToken, + e.AccessToken, + timestamp, + nonce) + + return &signingData{ + timestamp: timestamp, + authHeader: authHeader, + dataToSign: e.dataToSign(req, authHeader), + }, nil +} + +// dataToSign includes the information from the HTTP request that is relevant to ensuring that the request is authentic. +// This data set comprised of the request data combined with the authorization header value (excluding the signature field, +// but including the ; right before the signature field). +func (e *EdgeGridAuth) dataToSign(req *http.Request, authHeader string) string { + var buffer bytes.Buffer + + buffer.WriteString(req.Method) + buffer.WriteRune('\t') + buffer.WriteString(req.URL.Scheme) + buffer.WriteRune('\t') + buffer.WriteString(req.URL.Host) + buffer.WriteRune('\t') + buffer.WriteString(relativeURL(req.URL)) + buffer.WriteRune('\t') + buffer.WriteString(e.canonicalizedHeaders(req)) + buffer.WriteRune('\t') + buffer.WriteString(e.computeBodyHash(req)) + buffer.WriteRune('\t') + buffer.WriteString(authHeader) + + return buffer.String() +} + +// signingKey is derived from the client secret. +// The signing key is computed as the base64 encoding of the SHA–256 HMAC of the timestamp string +// (the field value included in the HTTP authorization header described above) with the client secret as the key. +func (e *EdgeGridAuth) signingKey(timestamp string) string { + return computeSignature(timestamp, e.ClientSecret) +} + +// realtiveURL is the part of the URL that starts from the root path and includes the query string, with the handling of following special cases: +// If the path is null or empty, set it to / (forward-slash). +// If the path does not start with /, add / to the beginning. +func relativeURL(url *url.URL) string { + relativeURL := url.Path + if relativeURL == "" { + return "/" + } + + if relativeURL[0] != '/' { + relativeURL = "/" + relativeURL + } + + if url.RawQuery != "" { + relativeURL += "?" + relativeURL += url.RawQuery + } + + return relativeURL +} + +// computeBodyHash returns the base64-encoded SHA–256 hash of the POST body. +// For any other request methods, this field is empty. But the tac separator (\t) must be included. +// The size of the POST body must be less than or equal to the value specified by the service. +// Any request that does not meet this criteria SHOULD be rejected during the signing process, +// as the request will be rejected by EdgeGrid. +func (e *EdgeGridAuth) computeBodyHash(req *http.Request) string { + if req.Body != nil { + bodyBytes, _ := ioutil.ReadAll(req.Body) + req.Body = ioutil.NopCloser(bytes.NewBuffer(bodyBytes)) + + if req.Method == http.MethodPost && len(bodyBytes) > 0 { + dataToHash := bodyBytes + if e.MaxBody != NoMaxBody && len(dataToHash) > e.MaxBody { + dataToHash = dataToHash[0:e.MaxBody] + } + sha256Sum := sha256.Sum256(dataToHash) + return base64.StdEncoding.EncodeToString(sha256Sum[:]) + } + } + + return "" +} + +// canonicalizedHeaders returns the request headers as a canonicalized string. +// +// The protocol does not support multiple request headers with the same header name. +// Such requests SHOULD be rejected during the signing process. Otherwise, EdgeGrid +// will not produce the intended results by rejecting such requests or removing all +// (but one) duplicated headers. +// +// Header names are case-insensitive per rfc2616. +// +// For each entry in the list of headers designated by the service provider to include +// in the signature in the specified order, the canonicalization of the request header +// is done as follows: +// +// Get the first header value for the name. +// Trim the leading and trailing white spaces. +// Replace all repeated white spaces with a single space. +// Concatenate the name:value pairs with the tab (\t) separator (name field is all in lower case). +// Terminate the headers with another tab (\t) separator. +// +// NOTE: The canonicalized data is used for creating the signature only, as this step +// might alter the header value. If a header in the list is not present in the request, +// or the header value is empty, nothing for that header, neither the name nor the tab +// separator, may be included. +func (e *EdgeGridAuth) canonicalizedHeaders(req *http.Request) string { + if len(e.HeadersToSign) < 1 { + return "" + } + + var headerNamesToSign []string + for headerName := range req.Header { + for _, sign := range e.HeadersToSign { + if strings.EqualFold(sign, headerName) { + headerNamesToSign = append(headerNamesToSign, headerName) + break + } + } + } + + if len(headerNamesToSign) < 1 { + return "" + } + + sort.Strings(headerNamesToSign) + + var buffer bytes.Buffer + for _, headerName := range headerNamesToSign { + for _, c := range headerName { + buffer.WriteRune(unicode.ToLower(c)) + } + + buffer.WriteRune(':') + + white := false + empty := true + for _, c := range req.Header.Get(headerName) { + if unicode.IsSpace(c) { + white = true + } else { + if white && !empty { + buffer.WriteRune(' ') + } + buffer.WriteRune(unicode.ToLower(c)) + empty = false + white = false + } + } + + buffer.WriteRune('\t') + } + + return buffer.String() +} + +// calculateSignature is the base64-encoding of the SHA–256 HMAC of the data to sign with the signing key. +func computeSignature(message string, secret string) string { + key := []byte(secret) + h := hmac.New(sha256.New, key) + h.Write([]byte(message)) + return base64.StdEncoding.EncodeToString(h.Sum(nil)) +} + +func createRandomNonce() (string, error) { + bytes := make([]byte, 18) + _, err := rand.Read(bytes) + if err != nil { + return "", err + } + + return base64.URLEncoding.EncodeToString(bytes), nil +} diff --git a/pkg/issuer/acme/dns/akamai/edgegridauth_test.go b/pkg/issuer/acme/dns/akamai/edgegridauth_test.go new file mode 100644 index 000000000..fab36573a --- /dev/null +++ b/pkg/issuer/acme/dns/akamai/edgegridauth_test.go @@ -0,0 +1,81 @@ +package akamai + +import ( + "net/http" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestDataToSign(t *testing.T) { + req, err := http.NewRequest( + http.MethodGet, + "https://akaa-xxxxxxxxxxxxxxxx-xxxxxxxxxxxxxxxx.luna.akamaiapis.net/diagnostic-tools/v1/locations", + http.NoBody) + assert.NoError(t, err) + + auth := NewEdgeGridAuth("ClientToken", "ClientSecret", "AccessToken") + auth.now = func() time.Time { + return time.Unix(1396461906, 0) // 20140402T18:05:06Z + } + auth.createNonce = func() (string, error) { + return "185f94eb-537c-4c01-b8cc-2fa5a06aee7f", nil + } + + data, err := auth.signingData(req) + assert.NoError(t, err) + + expected := "GET" + + "\thttps" + + "\takaa-xxxxxxxxxxxxxxxx-xxxxxxxxxxxxxxxx.luna.akamaiapis.net" + + "\t/diagnostic-tools/v1/locations" + + "\t" + // headers + "\t" + // content hash + "\tEG1-HMAC-SHA256 " + + "client_token=ClientToken;" + + "access_token=AccessToken;" + + "timestamp=20140402T18:05:06+0000;" + + "nonce=185f94eb-537c-4c01-b8cc-2fa5a06aee7f;" + + assert.EqualValues(t, expected, data.dataToSign) +} + +func TestDataToSignWithHeaders(t *testing.T) { + req, err := http.NewRequest( + http.MethodGet, + "http://akaa-xxxxxxxxxxxxxxxx-xxxxxxxxxxxxxxxx.luna-dev.akamaiapis.net/sample-api/v1/property/?fields=x&format=json&cpcode=1234", + http.NoBody) + assert.NoError(t, err) + + req.Header.Set("x-a", "va") + req.Header.Set("x-c", "\" xc \"") + req.Header.Set("x-b", " w b") + + auth := NewEdgeGridAuth( + "ClientToken", "ClientSecret", "AccessToken", + "x-c", "x-b", "x-a") + auth.now = func() time.Time { + return time.Unix(1376917283, 0) // 20130819T13:01:23Z + } + auth.createNonce = func() (string, error) { + return "ac392096-8aa1-44fd-8c3b-f797d35a6736", nil + } + + data, err := auth.signingData(req) + assert.NoError(t, err) + + expected := "GET" + + "\thttp" + + "\takaa-xxxxxxxxxxxxxxxx-xxxxxxxxxxxxxxxx.luna-dev.akamaiapis.net" + + "\t/sample-api/v1/property/?fields=x&format=json&cpcode=1234" + + "\tx-a:va\tx-b:w b\tx-c:\" xc \"\t" + + "\t" + // content hash + "\tEG1-HMAC-SHA256 " + + "client_token=ClientToken;" + + "access_token=AccessToken;" + + "timestamp=20130819T13:01:23+0000;" + + "nonce=ac392096-8aa1-44fd-8c3b-f797d35a6736;" + + assert.EqualValues(t, expected, data.dataToSign) +} diff --git a/pkg/issuer/acme/dns/dns.go b/pkg/issuer/acme/dns/dns.go index 644aec6bb..82315315d 100644 --- a/pkg/issuer/acme/dns/dns.go +++ b/pkg/issuer/acme/dns/dns.go @@ -9,7 +9,11 @@ import ( "k8s.io/client-go/kubernetes" corev1listers "k8s.io/client-go/listers/core/v1" + "github.com/pkg/errors" + "github.com/jetstack/cert-manager/pkg/apis/certmanager/v1alpha1" + + "github.com/jetstack/cert-manager/pkg/issuer/acme/dns/akamai" "github.com/jetstack/cert-manager/pkg/issuer/acme/dns/azuredns" "github.com/jetstack/cert-manager/pkg/issuer/acme/dns/clouddns" "github.com/jetstack/cert-manager/pkg/issuer/acme/dns/cloudflare" @@ -116,6 +120,30 @@ func (s *Solver) solverFor(crt *v1alpha1.Certificate, domain string) (solver, er var impl solver switch { + case providerConfig.Akamai != nil: + clientToken, err := s.loadSecretData(&providerConfig.Akamai.ClientToken) + if err != nil { + return nil, errors.Wrap(err, "error getting akamai client token") + } + + clientSecret, err := s.loadSecretData(&providerConfig.Akamai.ClientSecret) + if err != nil { + return nil, errors.Wrap(err, "error getting akamai client secret") + } + + accessToken, err := s.loadSecretData(&providerConfig.Akamai.AccessToken) + if err != nil { + return nil, errors.Wrap(err, "error getting akamai access token") + } + + impl, err = akamai.NewDNSProvider( + providerConfig.Akamai.ServiceConsumerDomain, + string(clientToken), + string(clientSecret), + string(accessToken)) + if err != nil { + return nil, errors.Wrap(err, "error instantiating akamai challenge solver") + } case providerConfig.CloudDNS != nil: saSecret, err := s.secretLister.Secrets(s.resourceNamespace).Get(providerConfig.CloudDNS.ServiceAccount.Name) if err != nil { @@ -189,3 +217,16 @@ func (s *Solver) solverFor(crt *v1alpha1.Certificate, domain string) (solver, er func NewSolver(issuer v1alpha1.GenericIssuer, client kubernetes.Interface, secretLister corev1listers.SecretLister, resourceNamespace string) *Solver { return &Solver{issuer, client, secretLister, resourceNamespace} } + +func (s *Solver) loadSecretData(selector *v1alpha1.SecretKeySelector) ([]byte, error) { + secret, err := s.secretLister.Secrets(s.resourceNamespace).Get(selector.Name) + if err != nil { + return nil, errors.Wrapf(err, "failed to load secret with name %q", selector.Name) + } + + if data, ok := secret.Data[selector.Key]; ok { + return data, nil + } + + return nil, errors.Errorf("no key %q in secret %q", selector.Key, selector.Name) +}