ARI: Implement GET portion of draft-ietf-acme-ari-00 (#6322)

Update our ACME Renewal Info implementation to parse
the CertID-based request format specified in the current
version of the draft specification.

Part of #6033
This commit is contained in:
Aaron Gable 2022-08-30 14:03:26 -07:00 committed by GitHub
parent f98d74c14d
commit 73b72e8fa2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 143 additions and 39 deletions

View File

@ -3,22 +3,33 @@
package integration
import (
"crypto"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"encoding/hex"
"crypto/x509/pkix"
"encoding/asn1"
"encoding/base64"
"fmt"
"math/big"
"net/http"
"os"
"strings"
"testing"
"github.com/letsencrypt/boulder/core"
"github.com/letsencrypt/boulder/test"
ocsp_helper "github.com/letsencrypt/boulder/test/ocsp/helper"
"golang.org/x/crypto/ocsp"
)
// certID matches the ASN.1 structure of the CertID sequence defined by RFC6960.
type certID struct {
HashAlgorithm pkix.AlgorithmIdentifier
IssuerNameHash []byte
IssuerKeyHash []byte
SerialNumber *big.Int
}
func TestARI(t *testing.T) {
t.Parallel()
// This test is gated on the ServeRenewalInfo feature flag.
@ -44,17 +55,25 @@ func TestARI(t *testing.T) {
// Leverage OCSP to get components of ARI request path.
issuer, err := ocsp_helper.GetIssuer(cert)
test.AssertNotError(t, err, "failed to get issuer cert")
ocspReqBytes, err := ocsp.CreateRequest(cert, issuer, nil)
ocspReqBytes, err := ocsp.CreateRequest(cert, issuer, &ocsp.RequestOptions{Hash: crypto.SHA256})
test.AssertNotError(t, err, "failed to build ocsp request")
ocspReq, err := ocsp.ParseRequest(ocspReqBytes)
test.AssertNotError(t, err, "failed to parse ocsp request")
// Make ARI request.
pathBytes, err := asn1.Marshal(certID{
pkix.AlgorithmIdentifier{ // SHA256
Algorithm: asn1.ObjectIdentifier{2, 16, 840, 1, 101, 3, 4, 2, 1},
Parameters: asn1.RawValue{Tag: 5 /* ASN.1 NULL */},
},
ocspReq.IssuerNameHash,
ocspReq.IssuerKeyHash,
cert.SerialNumber,
})
test.AssertNotError(t, err, "failed to marshal certID")
url := fmt.Sprintf(
"http://boulder:4001/get/draft-aaron-ari/renewalInfo/%s/%s/%s",
hex.EncodeToString(ocspReq.IssuerKeyHash),
hex.EncodeToString(ocspReq.IssuerNameHash),
core.SerialToString(cert.SerialNumber),
"http://boulder:4001/get/draft-ietf-acme-ari-00/renewalInfo/%s",
base64.RawURLEncoding.EncodeToString(pathBytes),
)
resp, err := http.Get(url)
test.AssertNotError(t, err, "ARI request should have succeeded")
@ -73,17 +92,25 @@ func TestARI(t *testing.T) {
// Get ARI path components.
issuer, err = ocsp_helper.GetIssuer(cert)
test.AssertNotError(t, err, "failed to get issuer cert")
ocspReqBytes, err = ocsp.CreateRequest(cert, issuer, nil)
ocspReqBytes, err = ocsp.CreateRequest(cert, issuer, &ocsp.RequestOptions{Hash: crypto.SHA256})
test.AssertNotError(t, err, "failed to build ocsp request")
ocspReq, err = ocsp.ParseRequest(ocspReqBytes)
test.AssertNotError(t, err, "failed to parse ocsp request")
// Make ARI request.
pathBytes, err = asn1.Marshal(certID{
pkix.AlgorithmIdentifier{ // SHA256
Algorithm: asn1.ObjectIdentifier{2, 16, 840, 1, 101, 3, 4, 2, 1},
Parameters: asn1.RawValue{Tag: 5 /* ASN.1 NULL */},
},
ocspReq.IssuerNameHash,
ocspReq.IssuerKeyHash,
cert.SerialNumber,
})
test.AssertNotError(t, err, "failed to marshal certID")
url = fmt.Sprintf(
"http://boulder:4001/get/draft-aaron-ari/renewalInfo/%s/%s/%s",
hex.EncodeToString(ocspReq.IssuerKeyHash),
hex.EncodeToString(ocspReq.IssuerNameHash),
core.SerialToString(cert.SerialNumber),
"http://boulder:4001/get/draft-ietf-acme-ari-00/renewalInfo/%s",
base64.RawURLEncoding.EncodeToString(pathBytes),
)
resp, err = http.Get(url)
test.AssertNotError(t, err, "ARI request should have succeeded")

View File

@ -3,10 +3,14 @@ package wfe2
import (
"context"
"crypto/x509"
"crypto/x509/pkix"
"encoding/asn1"
"encoding/base64"
"encoding/json"
"encoding/pem"
"errors"
"fmt"
"math/big"
"net"
"net/http"
"strconv"
@ -66,7 +70,7 @@ const (
getCertPath = getAPIPrefix + "cert/"
// Draft or likely-to-change paths
renewalInfoPath = getAPIPrefix + "draft-aaron-ari/renewalInfo/"
renewalInfoPath = getAPIPrefix + "draft-ietf-acme-ari-00/renewalInfo/"
// Non-ACME paths
aiaIssuerPath = "/aia/issuer/"
@ -2252,6 +2256,14 @@ func (wfe *WebFrontEndImpl) FinalizeOrder(ctx context.Context, logEvent *web.Req
}
}
// certID matches the ASN.1 structure of the CertID sequence defined by RFC6960.
type certID struct {
HashAlgorithm pkix.AlgorithmIdentifier
IssuerNameHash []byte
IssuerKeyHash []byte
SerialNumber *big.Int
}
// RenewalInfo is used to get information about the suggested renewal window
// for the given certificate. It only accepts unauthenticated GET requests.
func (wfe *WebFrontEndImpl) RenewalInfo(ctx context.Context, logEvent *web.RequestEvent, response http.ResponseWriter, request *http.Request) {
@ -2260,16 +2272,30 @@ func (wfe *WebFrontEndImpl) RenewalInfo(ctx context.Context, logEvent *web.Reque
return
}
uid := strings.SplitN(request.URL.Path, "/", 3)
if len(uid) != 3 {
wfe.sendError(response, logEvent, probs.Malformed("Path did not include exactly issuerKeyHash, issuerNameHash, and serialNumber"), nil)
// The path prefix has already been stripped, so request.URL.Path here is just
// the base64url-encoded DER CertID sequence.
der, err := base64.RawURLEncoding.DecodeString(request.URL.Path)
if err != nil {
wfe.sendError(response, logEvent, probs.Malformed("Path was not base64url-encoded: %w", err), nil)
return
}
// For now, discard issuerKeyHash and issuerNameHash, because *we* know
// (Boulder implementation-specific) that we do not re-use the same serial
// number across multiple different issuers.
serial := uid[2]
var id certID
rest, err := asn1.Unmarshal(der, &id)
if err != nil || len(rest) != 0 {
wfe.sendError(response, logEvent, probs.Malformed("Path was not a DER-encoded CertID sequence: %w", err), nil)
return
}
// Verify that the hash algorithm is SHA-256, so people don't use SHA-1 here.
if !id.HashAlgorithm.Algorithm.Equal(asn1.ObjectIdentifier{2, 16, 840, 1, 101, 3, 4, 2, 1}) {
wfe.sendError(response, logEvent, probs.Malformed("Request used hash algorithm other than SHA-256"), nil)
return
}
// We can do all of our processing based just on the serial, because Boulder
// does not re-use the same serial across multiple issuers.
serial := core.SerialToString(id.SerialNumber)
if !core.ValidSerial(serial) {
wfe.sendError(response, logEvent, probs.NotFound("Certificate not found"), nil)
return
@ -2289,6 +2315,11 @@ func (wfe *WebFrontEndImpl) RenewalInfo(ctx context.Context, logEvent *web.Reque
return
}
// TODO(#6033): Consider parsing cert.Der, using that to get its IssuerNameID,
// using that to look up the actual issuer cert in wfe.issuerCertificates,
// using that to compute the actual issuerNameHash and issuerKeyHash, and
// comparing those to the ones in the request.
// This is a very simple renewal calculation: Calculate a point 2/3rds of the
// way through the validity period, then give a 2-day window around that.
validity := time.Unix(0, cert.Expires).Add(time.Second).Sub(time.Unix(0, cert.Issued))

View File

@ -9,7 +9,9 @@ import (
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/hex"
"crypto/x509/pkix"
"encoding/asn1"
"encoding/base64"
"encoding/json"
"encoding/pem"
"errors"
@ -3513,36 +3515,80 @@ func TestARI(t *testing.T) {
test.AssertNotError(t, err, "failed to load test issuer")
// Take advantage of OCSP to build the issuer hashes.
ocspReqBytes, err := ocsp.CreateRequest(cert, issuer, nil)
ocspReqBytes, err := ocsp.CreateRequest(cert, issuer, &ocsp.RequestOptions{Hash: crypto.SHA256})
test.AssertNotError(t, err, "failed to create ocsp request")
ocspReq, err := ocsp.ParseRequest(ocspReqBytes)
test.AssertNotError(t, err, "failed to parse ocsp request")
// Ensure that a correct query results in a 200.
path := fmt.Sprintf(
"%s/%s/%s",
hex.EncodeToString(ocspReq.IssuerKeyHash),
hex.EncodeToString(ocspReq.IssuerNameHash),
core.SerialToString(cert.SerialNumber),
)
req, event := makeGet(path, renewalInfoPath)
idBytes, err := asn1.Marshal(certID{
pkix.AlgorithmIdentifier{ // SHA256
Algorithm: asn1.ObjectIdentifier{2, 16, 840, 1, 101, 3, 4, 2, 1},
Parameters: asn1.RawValue{Tag: 5 /* ASN.1 NULL */},
},
ocspReq.IssuerNameHash,
ocspReq.IssuerKeyHash,
cert.SerialNumber,
})
test.AssertNotError(t, err, "failed to marshal certID")
req, event := makeGet(base64.RawURLEncoding.EncodeToString(idBytes), renewalInfoPath)
resp := httptest.NewRecorder()
wfe.RenewalInfo(context.Background(), event, resp, req)
test.AssertEquals(t, resp.Code, 200)
test.AssertEquals(t, resp.Code, http.StatusOK)
test.AssertEquals(t, resp.Header().Get("Retry-After"), "21600")
// Ensure that a mangled query (wrong serial) results in a 404.
path = fmt.Sprintf(
"%s/%s/%s",
hex.EncodeToString(ocspReq.IssuerKeyHash),
hex.EncodeToString(ocspReq.IssuerNameHash),
core.SerialToString(big.NewInt(0).Add(cert.SerialNumber, big.NewInt(1))),
)
req, event = makeGet(path, renewalInfoPath)
// Ensure that a query for a non-existent serial results in a 404.
idBytes, err = asn1.Marshal(certID{
pkix.AlgorithmIdentifier{ // SHA256
Algorithm: asn1.ObjectIdentifier{2, 16, 840, 1, 101, 3, 4, 2, 1},
Parameters: asn1.RawValue{Tag: 5 /* ASN.1 NULL */},
},
ocspReq.IssuerNameHash,
ocspReq.IssuerKeyHash,
big.NewInt(0).Add(cert.SerialNumber, big.NewInt(1)),
})
test.AssertNotError(t, err, "failed to marshal certID")
req, event = makeGet(base64.RawURLEncoding.EncodeToString(idBytes), renewalInfoPath)
resp = httptest.NewRecorder()
wfe.RenewalInfo(context.Background(), event, resp, req)
test.AssertEquals(t, resp.Code, 404)
test.AssertEquals(t, resp.Code, http.StatusNotFound)
test.AssertEquals(t, resp.Header().Get("Retry-After"), "")
// Ensure that a query with a bad hash algorithm fails.
idBytes, err = asn1.Marshal(certID{
pkix.AlgorithmIdentifier{ // SHA-1
Algorithm: asn1.ObjectIdentifier{1, 3, 14, 3, 2, 26},
Parameters: asn1.RawValue{Tag: 5 /* ASN.1 NULL */},
},
ocspReq.IssuerNameHash,
ocspReq.IssuerKeyHash,
big.NewInt(0).Add(cert.SerialNumber, big.NewInt(1)),
})
test.AssertNotError(t, err, "failed to marshal certID")
req, event = makeGet(base64.RawURLEncoding.EncodeToString(idBytes), renewalInfoPath)
resp = httptest.NewRecorder()
wfe.RenewalInfo(context.Background(), event, resp, req)
test.AssertEquals(t, resp.Code, http.StatusBadRequest)
// Ensure that a query with a non-CertID path fails.
req, event = makeGet(base64.RawURLEncoding.EncodeToString(ocspReqBytes), renewalInfoPath)
resp = httptest.NewRecorder()
wfe.RenewalInfo(context.Background(), event, resp, req)
test.AssertEquals(t, resp.Code, http.StatusBadRequest)
// Ensure that a query with a non-Base64URL path (including one in the old
// request path style, which included slashes) fails.
req, event = makeGet(
fmt.Sprintf(
"%s/%s/%s",
base64.RawURLEncoding.EncodeToString(ocspReq.IssuerNameHash),
base64.RawURLEncoding.EncodeToString(ocspReq.IssuerKeyHash),
base64.RawURLEncoding.EncodeToString(cert.SerialNumber.Bytes()),
),
renewalInfoPath)
resp = httptest.NewRecorder()
wfe.RenewalInfo(context.Background(), event, resp, req)
test.AssertEquals(t, resp.Code, http.StatusBadRequest)
}
// TODO(#6011): Remove once TLS 1.0 and 1.1 support is gone.