Skip to content

Commit

Permalink
feat: update CertID format as per draft-ietf-acme-ari-02 (#2066)
Browse files Browse the repository at this point in the history
  • Loading branch information
beautifulentropy committed Jan 24, 2024
1 parent bb83067 commit 3c73f62
Show file tree
Hide file tree
Showing 7 changed files with 33 additions and 182 deletions.
2 changes: 1 addition & 1 deletion .golangci.yml
Expand Up @@ -236,4 +236,4 @@ issues:
- path: providers/dns/hosttech/internal/client_test.go
text: 'Duplicate words \(0\) found'
- path: cmd/cmd_renew.go
text: 'cyclomatic complexity 16 of func `renewForDomains` is high'
text: 'cyclomatic complexity 15 of func `renewForDomains` is high'
2 changes: 1 addition & 1 deletion README.md
Expand Up @@ -16,7 +16,7 @@ Let's Encrypt client and ACME library written in Go.
- ACME v2 [RFC 8555](https://www.rfc-editor.org/rfc/rfc8555.html)
- Support [RFC 8737](https://www.rfc-editor.org/rfc/rfc8737.html): TLS Application‑Layer Protocol Negotiation (ALPN) Challenge Extension
- Support [RFC 8738](https://www.rfc-editor.org/rfc/rfc8738.html): certificates for IP addresses
- Support [draft-ietf-acme-ari-01](https://datatracker.ietf.org/doc/draft-ietf-acme-ari/): Renewal Information (ARI) Extension
- Support [draft-ietf-acme-ari-02](https://datatracker.ietf.org/doc/draft-ietf-acme-ari/): Renewal Information (ARI) Extension
- Register with CA
- Obtain certificates, both from scratch or with an existing CSR
- Renew certificates
Expand Down
6 changes: 4 additions & 2 deletions acme/commons.go
Expand Up @@ -329,9 +329,11 @@ type RenewalInfoResponse struct {
}

// RenewalInfoUpdateRequest is the JWS payload for POST requests made to the renewalInfo endpoint.
// - (4.2. Updating Renewal Information) https://datatracker.ietf.org/doc/draft-ietf-acme-ari/
// - (4.2. RenewalInfo Objects) https://datatracker.ietf.org/doc/html/draft-ietf-acme-ari-02#section-4.2
type RenewalInfoUpdateRequest struct {
// CertID is the base64url-encoded [RFC4648] bytes of a DER-encoded CertID ASN.1 sequence [RFC6960] with any trailing '=' characters stripped.
// CertID is a composite string in the format: base64url(AKI) || '.' || base64url(Serial), where AKI is the
// certificate's authority key identifier and Serial is the certificate's serial number. For details, see:
// https://datatracker.ietf.org/doc/html/draft-ietf-acme-ari-02#section-4.1
CertID string `json:"certID"`
// Replaced is required and indicates whether or not the client considers the certificate to have been replaced.
// A certificate is considered replaced when its revocation would not disrupt any ongoing services,
Expand Down
104 changes: 11 additions & 93 deletions certificate/renewal.go
@@ -1,14 +1,11 @@
package certificate

import (
"crypto"
"crypto/x509"
"crypto/x509/pkix"
"encoding/asn1"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"math/big"
"math/rand"
"strings"
"time"
Expand All @@ -18,11 +15,7 @@ import (

// RenewalInfoRequest contains the necessary renewal information.
type RenewalInfoRequest struct {
Cert *x509.Certificate
Issuer *x509.Certificate
// HashName must be the string representation of a crypto.Hash constant in the golang.org/x/crypto package (e.g. "SHA-256").
// The correct value depends on the algorithm expected by the ACME server's ARI implementation.
HashName string
Cert *x509.Certificate
}

// RenewalInfoResponse is a wrapper around acme.RenewalInfoResponse that provides a method for determining when to renew a certificate.
Expand Down Expand Up @@ -72,7 +65,7 @@ func (r *RenewalInfoResponse) ShouldRenewAt(now time.Time, willingToSleep time.D
//
// https://datatracker.ietf.org/doc/draft-ietf-acme-ari
func (c *Certifier) GetRenewalInfo(req RenewalInfoRequest) (*RenewalInfoResponse, error) {
certID, err := makeCertID(req.Cert, req.Issuer, req.HashName)
certID, err := makeARICertID(req.Cert)
if err != nil {
return nil, fmt.Errorf("error making certID: %w", err)
}
Expand Down Expand Up @@ -100,7 +93,7 @@ func (c *Certifier) GetRenewalInfo(req RenewalInfoRequest) (*RenewalInfoResponse
//
// https://datatracker.ietf.org/doc/draft-ietf-acme-ari
func (c *Certifier) UpdateRenewalInfo(req RenewalInfoRequest) error {
certID, err := makeCertID(req.Cert, req.Issuer, req.HashName)
certID, err := makeARICertID(req.Cert)
if err != nil {
return fmt.Errorf("error making certID: %w", err)
}
Expand All @@ -116,89 +109,14 @@ func (c *Certifier) UpdateRenewalInfo(req RenewalInfoRequest) error {
return nil
}

// makeCertID returns a base64url-encoded string that uniquely identifies a certificate to endpoints
// that implement the draft-ietf-acme-ari specification: https://datatracker.ietf.org/doc/draft-ietf-acme-ari.
// hashName must be the string representation of a crypto.Hash constant in the golang.org/x/crypto package.
// Supported hash functions are SHA-1, SHA-256, SHA-384, and SHA-512.
func makeCertID(leaf, issuer *x509.Certificate, hashName string) (string, error) {
// makeARICertID constructs a certificate identifier as described in draft-ietf-acme-ari-02, section 4.1.
func makeARICertID(leaf *x509.Certificate) (string, error) {
if leaf == nil {
return "", fmt.Errorf("leaf certificate is nil")
return "", errors.New("leaf certificate is nil")
}
if issuer == nil {
return "", fmt.Errorf("issuer certificate is nil")
}

var hashFunc crypto.Hash
var oid asn1.ObjectIdentifier

switch hashName {
// The following correlation of hashFunc to OID is copied from a private mapping in golang.org/x/crypto/ocsp:
// https://cs.opensource.google/go/x/crypto/+/refs/tags/v0.8.0:ocsp/ocsp.go;l=156
case crypto.SHA1.String():
hashFunc = crypto.SHA1
oid = asn1.ObjectIdentifier([]int{1, 3, 14, 3, 2, 26})

case crypto.SHA256.String():
hashFunc = crypto.SHA256
oid = asn1.ObjectIdentifier([]int{2, 16, 840, 1, 101, 3, 4, 2, 1})

case crypto.SHA384.String():
hashFunc = crypto.SHA384
oid = asn1.ObjectIdentifier([]int{2, 16, 840, 1, 101, 3, 4, 2, 2})

case crypto.SHA512.String():
hashFunc = crypto.SHA512
oid = asn1.ObjectIdentifier([]int{2, 16, 840, 1, 101, 3, 4, 2, 3})

default:
return "", fmt.Errorf("hashName %q is not supported by this package", hashName)
}

if !hashFunc.Available() {
// This should never happen.
return "", fmt.Errorf("hash function %q is not available on your platform", hashFunc)
}

var spki struct {
Algorithm pkix.AlgorithmIdentifier
PublicKey asn1.BitString
}

_, err := asn1.Unmarshal(issuer.RawSubjectPublicKeyInfo, &spki)
if err != nil {
return "", err
}
h := hashFunc.New()
h.Write(spki.PublicKey.RightAlign())
issuerKeyHash := h.Sum(nil)

h.Reset()
h.Write(issuer.RawSubject)
issuerNameHash := h.Sum(nil)

type certID struct {
HashAlgorithm pkix.AlgorithmIdentifier
IssuerNameHash []byte
IssuerKeyHash []byte
SerialNumber *big.Int
}

// DER-encode the CertID ASN.1 sequence [RFC6960].
certIDBytes, err := asn1.Marshal(certID{
HashAlgorithm: pkix.AlgorithmIdentifier{
Algorithm: oid,
},
IssuerNameHash: issuerNameHash,
IssuerKeyHash: issuerKeyHash,
SerialNumber: leaf.SerialNumber,
})
if err != nil {
return "", err
}

// base64url-encode [RFC4648] the bytes of the DER-encoded CertID ASN.1 sequence [RFC6960].
encodedBytes := base64.URLEncoding.EncodeToString(certIDBytes)

// Any trailing '=' characters MUST be stripped.
return strings.TrimRight(encodedBytes, "="), nil
return fmt.Sprintf("%s.%s",
strings.TrimRight(base64.URLEncoding.EncodeToString(leaf.AuthorityKeyId), "="),
strings.TrimRight(base64.URLEncoding.EncodeToString(leaf.SerialNumber.Bytes()), "="),
), nil
}
57 changes: 7 additions & 50 deletions certificate/renewal_test.go
@@ -1,7 +1,6 @@
package certificate

import (
"crypto"
"crypto/rand"
"crypto/rsa"
"encoding/json"
Expand Down Expand Up @@ -40,45 +39,21 @@ TXysJCeyiGnR+KOOjOOQ9ZlO5JUK3OE4hagPLfaIpDDy6RXQt3ss0iNLuB1+IOtp
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"
ariLeafCertID = "OM8w0VGlx1SqpUk1pFCxlOMxmaU.PqNFaGVEHxw"
)

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())
actual, err := makeARICertID(leaf)
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)
Expand Down Expand Up @@ -109,7 +84,7 @@ func TestCertifier_GetRenewalInfo(t *testing.T) {

certifier := NewCertifier(core, &resolverMock{}, CertifierOptions{KeyType: certcrypto.RSA2048})

ri, err := certifier.GetRenewalInfo(RenewalInfoRequest{leaf, issuer, crypto.SHA256.String()})
ri, err := certifier.GetRenewalInfo(RenewalInfoRequest{leaf})
require.NoError(t, err)
require.NotNil(t, ri)
assert.Equal(t, "2020-03-17T17:51:09Z", ri.SuggestedWindow.Start.Format(time.RFC3339))
Expand All @@ -120,8 +95,6 @@ func TestCertifier_GetRenewalInfo(t *testing.T) {
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")
Expand All @@ -135,7 +108,7 @@ func TestCertifier_GetRenewalInfo_errors(t *testing.T) {
{
desc: "API timeout",
httpClient: &http.Client{Timeout: 500 * time.Millisecond}, // HTTP client that times out after 500ms.
request: RenewalInfoRequest{leaf, issuer, crypto.SHA256.String()},
request: RenewalInfoRequest{leaf},
handler: func(w http.ResponseWriter, r *http.Request) {
// API that takes 2ms to respond.
time.Sleep(2 * time.Millisecond)
Expand All @@ -144,20 +117,12 @@ func TestCertifier_GetRenewalInfo_errors(t *testing.T) {
{
desc: "API error",
httpClient: http.DefaultClient,
request: RenewalInfoRequest{leaf, issuer, crypto.SHA256.String()},
request: RenewalInfoRequest{leaf},
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 {
Expand All @@ -183,8 +148,6 @@ func TestCertifier_GetRenewalInfo_errors(t *testing.T) {
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")
Expand Down Expand Up @@ -217,15 +180,13 @@ func TestCertifier_UpdateRenewalInfo(t *testing.T) {

certifier := NewCertifier(core, &resolverMock{}, CertifierOptions{KeyType: certcrypto.RSA2048})

err = certifier.UpdateRenewalInfo(RenewalInfoRequest{leaf, issuer, crypto.SHA256.String()})
err = certifier.UpdateRenewalInfo(RenewalInfoRequest{leaf})
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")
Expand All @@ -236,11 +197,7 @@ func TestCertifier_UpdateRenewalInfo_errors(t *testing.T) {
}{
{
desc: "API error",
request: RenewalInfoRequest{leaf, issuer, crypto.SHA256.String()},
},
{
desc: "Certificate is nil",
request: RenewalInfoRequest{nil, issuer, crypto.SHA256.String()},
request: RenewalInfoRequest{leaf},
},
}

Expand Down

0 comments on commit 3c73f62

Please sign in to comment.