Skip to content

Commit

Permalink
feat: adding OCSP revocation implementation (#134)
Browse files Browse the repository at this point in the history
This PR adds a new package that will perform OCSP revocation checking for a certificate chain and addresses part of issue #124. Implementation is based on the design from #132 and the specification
[here](https://github.com/notaryproject/notaryproject/blob/main/specs/trust-store-trust-policy.md#certificate-revocation-evaluation).

Signed-off-by: Kody Kimberl <kody.kimberl.work@gmail.com>
  • Loading branch information
kody-kimberl authored Apr 20, 2023
1 parent 010204d commit cefe2ef
Show file tree
Hide file tree
Showing 15 changed files with 2,134 additions and 0 deletions.
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ require (
github.com/fxamacker/cbor/v2 v2.4.0
github.com/golang-jwt/jwt/v4 v4.5.0
github.com/veraison/go-cose v1.0.0
golang.org/x/crypto v0.7.0
)

require github.com/x448/float16 v0.8.4 // indirect
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,5 @@ github.com/veraison/go-cose v1.0.0 h1:Jxirc0rl3gG7wUFgW+82tBQNeK8T8e2Bk1Vd298ob4
github.com/veraison/go-cose v1.0.0/go.mod h1:7ziE85vSq4ScFTg6wyoMXjucIGOf4JkFEZi/an96Ct4=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
golang.org/x/crypto v0.7.0 h1:AvwMYaRytfdeVt3u6mLaxYtErKYjxA2OXjJ1HHq6t3A=
golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU=
55 changes: 55 additions & 0 deletions revocation/ocsp/errors.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
// Package ocsp provides methods for checking the OCSP revocation status of a
// certificate chain, as well as errors related to these checks
package ocsp

import (
"fmt"
"time"
)

// RevokedError is returned when the certificate's status for OCSP is
// ocsp.Revoked
type RevokedError struct{}

func (e RevokedError) Error() string {
return "certificate is revoked via OCSP"
}

// UnknownStatusError is returned when the certificate's status for OCSP is
// ocsp.Unknown
type UnknownStatusError struct{}

func (e UnknownStatusError) Error() string {
return "certificate has unknown status via OCSP"
}

// GenericError is returned when there is an error during the OCSP revocation
// check, not necessarily a revocation
type GenericError struct {
Err error
}

func (e GenericError) Error() string {
msg := "error checking revocation status via OCSP"
if e.Err != nil {
return fmt.Sprintf("%s: %v", msg, e.Err)
}
return msg
}

// NoServerError is returned when the OCSPServer is not specified.
type NoServerError struct{}

func (e NoServerError) Error() string {
return "no valid OCSP server found"
}

// TimeoutError is returned when the connection attempt to an OCSP URL exceeds
// the specified threshold
type TimeoutError struct {
timeout time.Duration
}

func (e TimeoutError) Error() string {
return fmt.Sprintf("exceeded timeout threshold of %.2f seconds for OCSP check", e.timeout.Seconds())
}
65 changes: 65 additions & 0 deletions revocation/ocsp/errors_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
package ocsp

import (
"errors"
"fmt"
"testing"
"time"
)

func TestRevokedError(t *testing.T) {
err := &RevokedError{}
expectedMsg := "certificate is revoked via OCSP"

if err.Error() != expectedMsg {
t.Errorf("Expected %v but got %v", expectedMsg, err.Error())
}
}

func TestUnknownStatusError(t *testing.T) {
err := &UnknownStatusError{}
expectedMsg := "certificate has unknown status via OCSP"

if err.Error() != expectedMsg {
t.Errorf("Expected %v but got %v", expectedMsg, err.Error())
}
}

func TestGenericError(t *testing.T) {
t.Run("without_inner_error", func(t *testing.T) {
err := &GenericError{}
expectedMsg := "error checking revocation status via OCSP"

if err.Error() != expectedMsg {
t.Errorf("Expected %v but got %v", expectedMsg, err.Error())
}
})

t.Run("with_inner_error", func(t *testing.T) {
err := &GenericError{Err: errors.New("inner error")}
expectedMsg := "error checking revocation status via OCSP: inner error"

if err.Error() != expectedMsg {
t.Errorf("Expected %v but got %v", expectedMsg, err.Error())
}
})
}

func TestNoServerError(t *testing.T) {
err := &NoServerError{}
expectedMsg := "no valid OCSP server found"

if err.Error() != expectedMsg {
t.Errorf("Expected %v but got %v", expectedMsg, err.Error())
}
}

func TestTimeoutError(t *testing.T) {
duration := 5 * time.Second
err := &TimeoutError{duration}
expectedMsg := fmt.Sprintf("exceeded timeout threshold of %.2f seconds for OCSP check", duration.Seconds())

if err.Error() != expectedMsg {
t.Errorf("Expected %v but got %v", expectedMsg, err.Error())
}
}
243 changes: 243 additions & 0 deletions revocation/ocsp/ocsp.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,243 @@
// Package ocsp provides methods for checking the OCSP revocation status of a
// certificate chain, as well as errors related to these checks
package ocsp

import (
"bytes"
"crypto"
"crypto/x509"
"crypto/x509/pkix"
"encoding/asn1"
"encoding/base64"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"sync"
"time"

"github.com/notaryproject/notation-core-go/revocation/result"
coreX509 "github.com/notaryproject/notation-core-go/x509"
"golang.org/x/crypto/ocsp"
)

// Options specifies values that are needed to check OCSP revocation
type Options struct {
CertChain []*x509.Certificate
SigningTime time.Time
HTTPClient *http.Client
}

const (
pkixNoCheckOID string = "1.3.6.1.5.5.7.48.1.5"
invalidityDateOID string = "2.5.29.24"
// Max size determined from https://www.ibm.com/docs/en/sva/9.0.6?topic=stanza-ocsp-max-response-size.
// Typical size is ~4 KB
ocspMaxResponseSize int64 = 20480 //bytes
)

// CheckStatus checks OCSP based on the passed options and returns an array of
// result.CertRevocationResult objects that contains the results and error. The
// length of this array will always be equal to the length of the certificate
// chain.
func CheckStatus(opts Options) ([]*result.CertRevocationResult, error) {
if len(opts.CertChain) == 0 {
return nil, result.InvalidChainError{Err: errors.New("chain does not contain any certificates")}
}

// Validate cert chain structure
// Since this is using authentic signing time, signing time may be zero.
// Thus, it is better to pass nil here than fail for a cert's NotBefore
// being after zero time
if err := coreX509.ValidateCodeSigningCertChain(opts.CertChain, nil); err != nil {
return nil, result.InvalidChainError{Err: err}
}

certResults := make([]*result.CertRevocationResult, len(opts.CertChain))

// Check status for each cert in cert chain
var wg sync.WaitGroup
for i, cert := range opts.CertChain[:len(opts.CertChain)-1] {
wg.Add(1)
// Assume cert chain is accurate and next cert in chain is the issuer
go func(i int, cert *x509.Certificate) {
defer wg.Done()
certResults[i] = certCheckStatus(cert, opts.CertChain[i+1], opts)
}(i, cert)
}
// Last is root cert, which will never be revoked by OCSP
certResults[len(opts.CertChain)-1] = &result.CertRevocationResult{
Result: result.ResultNonRevokable,
ServerResults: []*result.ServerResult{{
Result: result.ResultNonRevokable,
Error: nil,
}},
}

wg.Wait()
return certResults, nil
}

func certCheckStatus(cert, issuer *x509.Certificate, opts Options) *result.CertRevocationResult {
ocspURLs := cert.OCSPServer
if len(ocspURLs) == 0 {
// OCSP not enabled for this certificate.
return &result.CertRevocationResult{
Result: result.ResultNonRevokable,
ServerResults: []*result.ServerResult{toServerResult("", NoServerError{})},
}
}

serverResults := make([]*result.ServerResult, len(ocspURLs))
for serverIndex, server := range ocspURLs {
serverResult := checkStatusFromServer(cert, issuer, server, opts)
if serverResult.Result == result.ResultOK ||
serverResult.Result == result.ResultRevoked ||
(serverResult.Result == result.ResultUnknown && errors.Is(serverResult.Error, UnknownStatusError{})) {
// A valid response has been received from an OCSP server
// Result should be based on only this response, not any errors from
// other servers
return serverResultsToCertRevocationResult([]*result.ServerResult{serverResult})
}
serverResults[serverIndex] = serverResult
}
return serverResultsToCertRevocationResult(serverResults)
}

func checkStatusFromServer(cert, issuer *x509.Certificate, server string, opts Options) *result.ServerResult {
// Check valid server
if serverURL, err := url.Parse(server); err != nil || !strings.EqualFold(serverURL.Scheme, "http") {
// This function is only able to check servers that are accessible via HTTP
return toServerResult(server, GenericError{Err: fmt.Errorf("OCSPServer protocol %s is not supported", serverURL.Scheme)})
}

// Create OCSP Request
resp, err := executeOCSPCheck(cert, issuer, server, opts)
if err != nil {
// If there is a server error, attempt all servers before determining what to return
// to the user
return toServerResult(server, err)
}

// Validate OCSP response isn't expired
if time.Now().After(resp.NextUpdate) {
return toServerResult(server, GenericError{Err: errors.New("expired OCSP response")})
}

// Handle pkix-ocsp-no-check and id-ce-invalidityDate extensions if present
// in response
extensionMap := extensionsToMap(resp.Extensions)
if _, foundNoCheck := extensionMap[pkixNoCheckOID]; !foundNoCheck {
// This will be ignored until CRL is implemented
// If it isn't found, CRL should be used to verify the OCSP response
_ = foundNoCheck // needed to bypass linter warnings (Remove after adding CRL)
// TODO: add CRL support
// https://github.com/notaryproject/notation-core-go/issues/125
}
if invalidityDateBytes, foundInvalidityDate := extensionMap[invalidityDateOID]; foundInvalidityDate && !opts.SigningTime.IsZero() && resp.Status == ocsp.Revoked {
var invalidityDate time.Time
rest, err := asn1.UnmarshalWithParams(invalidityDateBytes, &invalidityDate, "generalized")
if len(rest) == 0 && err == nil && opts.SigningTime.Before(invalidityDate) {
return toServerResult(server, nil)
}
}

// No errors, valid server response
switch resp.Status {
case ocsp.Good:
return toServerResult(server, nil)
case ocsp.Revoked:
return toServerResult(server, RevokedError{})
default:
// ocsp.Unknown
return toServerResult(server, UnknownStatusError{})
}
}

func extensionsToMap(extensions []pkix.Extension) map[string][]byte {
extensionMap := make(map[string][]byte)
for _, extension := range extensions {
extensionMap[extension.Id.String()] = extension.Value
}
return extensionMap
}

func executeOCSPCheck(cert, issuer *x509.Certificate, server string, opts Options) (*ocsp.Response, error) {
// TODO: Look into other alternatives for specifying the Hash
// https://github.com/notaryproject/notation-core-go/issues/139
ocspRequest, err := ocsp.CreateRequest(cert, issuer, &ocsp.RequestOptions{Hash: crypto.SHA256})
if err != nil {
return nil, GenericError{Err: err}
}

var resp *http.Response
if base64.URLEncoding.EncodedLen(len(ocspRequest)) >= 255 {
reader := bytes.NewReader(ocspRequest)
resp, err = opts.HTTPClient.Post(server, "application/ocsp-request", reader)
} else {
encodedReq := base64.URLEncoding.EncodeToString(ocspRequest)
var reqURL string
reqURL, err = url.JoinPath(server, encodedReq)
if err != nil {
return nil, GenericError{Err: err}
}
resp, err = opts.HTTPClient.Get(reqURL)
}

if err != nil {
var urlErr *url.Error
if errors.As(err, &urlErr) && urlErr.Timeout() {
return nil, TimeoutError{}
}
return nil, GenericError{Err: err}
}
defer resp.Body.Close()

if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("failed to retrieve OCSP: response had status code %d", resp.StatusCode)
}

body, err := io.ReadAll(io.LimitReader(resp.Body, ocspMaxResponseSize))
if err != nil {
return nil, GenericError{Err: err}
}

switch {
case bytes.Equal(body, ocsp.UnauthorizedErrorResponse):
return nil, GenericError{Err: errors.New("OCSP unauthorized")}
case bytes.Equal(body, ocsp.MalformedRequestErrorResponse):
return nil, GenericError{Err: errors.New("OCSP malformed")}
case bytes.Equal(body, ocsp.InternalErrorErrorResponse):
return nil, GenericError{Err: errors.New("OCSP internal error")}
case bytes.Equal(body, ocsp.TryLaterErrorResponse):
return nil, GenericError{Err: errors.New("OCSP try later")}
case bytes.Equal(body, ocsp.SigRequredErrorResponse):
return nil, GenericError{Err: errors.New("OCSP signature required")}
}

return ocsp.ParseResponseForCert(body, cert, issuer)
}

func toServerResult(server string, err error) *result.ServerResult {
switch t := err.(type) {
case nil:
return result.NewServerResult(result.ResultOK, server, nil)
case NoServerError:
return result.NewServerResult(result.ResultNonRevokable, server, nil)
case RevokedError:
return result.NewServerResult(result.ResultRevoked, server, t)
default:
// Includes GenericError, UnknownStatusError, result.InvalidChainError,
// and TimeoutError
return result.NewServerResult(result.ResultUnknown, server, t)
}
}

func serverResultsToCertRevocationResult(serverResults []*result.ServerResult) *result.CertRevocationResult {
return &result.CertRevocationResult{
Result: serverResults[len(serverResults)-1].Result,
ServerResults: serverResults,
}
}
Loading

0 comments on commit cefe2ef

Please sign in to comment.