diff --git a/go.mod b/go.mod index 152f6ce7..81ad900c 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index cabcaa5e..5c30694d 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/revocation/ocsp/errors.go b/revocation/ocsp/errors.go new file mode 100644 index 00000000..8f52167b --- /dev/null +++ b/revocation/ocsp/errors.go @@ -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()) +} diff --git a/revocation/ocsp/errors_test.go b/revocation/ocsp/errors_test.go new file mode 100644 index 00000000..419498d9 --- /dev/null +++ b/revocation/ocsp/errors_test.go @@ -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()) + } +} diff --git a/revocation/ocsp/ocsp.go b/revocation/ocsp/ocsp.go new file mode 100644 index 00000000..54ffab85 --- /dev/null +++ b/revocation/ocsp/ocsp.go @@ -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, + } +} diff --git a/revocation/ocsp/ocsp_test.go b/revocation/ocsp/ocsp_test.go new file mode 100644 index 00000000..1bd81b25 --- /dev/null +++ b/revocation/ocsp/ocsp_test.go @@ -0,0 +1,672 @@ +package ocsp + +import ( + "crypto/x509" + "errors" + "fmt" + "net/http" + "testing" + "time" + + "github.com/notaryproject/notation-core-go/revocation/result" + "github.com/notaryproject/notation-core-go/testhelper" + "golang.org/x/crypto/ocsp" +) + +func validateEquivalentCertResults(certResults, expectedCertResults []*result.CertRevocationResult, t *testing.T) { + if len(certResults) != len(expectedCertResults) { + t.Errorf("Length of certResults (%d) did not match expected length (%d)", len(certResults), len(expectedCertResults)) + return + } + for i, certResult := range certResults { + if certResult.Result != expectedCertResults[i].Result { + t.Errorf("Expected certResults[%d].Result to be %s, but got %s", i, expectedCertResults[i].Result, certResult.Result) + } + if len(certResult.ServerResults) != len(expectedCertResults[i].ServerResults) { + t.Errorf("Length of certResults[%d].ServerResults (%d) did not match expected length (%d)", i, len(certResult.ServerResults), len(expectedCertResults[i].ServerResults)) + return + } + for j, serverResult := range certResult.ServerResults { + if serverResult.Result != expectedCertResults[i].ServerResults[j].Result { + t.Errorf("Expected certResults[%d].ServerResults[%d].Result to be %s, but got %s", i, j, expectedCertResults[i].ServerResults[j].Result, serverResult.Result) + } + if serverResult.Server != expectedCertResults[i].ServerResults[j].Server { + t.Errorf("Expected certResults[%d].ServerResults[%d].Server to be %s, but got %s", i, j, expectedCertResults[i].ServerResults[j].Server, serverResult.Server) + } + if serverResult.Error == nil { + if expectedCertResults[i].ServerResults[j].Error == nil { + continue + } + t.Errorf("certResults[%d].ServerResults[%d].Error was nil, but expected %v", i, j, expectedCertResults[i].ServerResults[j].Error) + } else if expectedCertResults[i].ServerResults[j].Error == nil { + t.Errorf("Unexpected error for certResults[%d].ServerResults[%d].Error: %v", i, j, serverResult.Error) + } else if serverResult.Error.Error() != expectedCertResults[i].ServerResults[j].Error.Error() { + t.Errorf("Expected certResults[%d].ServerResults[%d].Error to be %v, but got %v", i, j, expectedCertResults[i].ServerResults[j].Error, serverResult.Error) + } + } + } +} + +func getOKCertResult(server string) *result.CertRevocationResult { + return &result.CertRevocationResult{ + Result: result.ResultOK, + ServerResults: []*result.ServerResult{ + result.NewServerResult(result.ResultOK, server, nil), + }, + } +} + +func getRootCertResult() *result.CertRevocationResult { + return &result.CertRevocationResult{ + Result: result.ResultNonRevokable, + ServerResults: []*result.ServerResult{ + result.NewServerResult(result.ResultNonRevokable, "", nil), + }, + } +} + +func TestCheckStatus(t *testing.T) { + revokableCertTuple := testhelper.GetRevokableRSALeafCertificate() + revokableIssuerTuple := testhelper.GetRSARootCertificate() + ocspServer := revokableCertTuple.Cert.OCSPServer[0] + revokableChain := []*x509.Certificate{revokableCertTuple.Cert, revokableIssuerTuple.Cert} + testChain := []testhelper.RSACertTuple{revokableCertTuple, revokableIssuerTuple} + + t.Run("check non-revoked cert", func(t *testing.T) { + client := testhelper.MockClient(testChain, []ocsp.ResponseStatus{ocsp.Good}, nil, true) + opts := Options{ + CertChain: revokableChain, + SigningTime: time.Now(), + HTTPClient: client, + } + + certResult := certCheckStatus(revokableChain[0], revokableChain[1], opts) + expectedCertResults := []*result.CertRevocationResult{getOKCertResult(ocspServer)} + validateEquivalentCertResults([]*result.CertRevocationResult{certResult}, expectedCertResults, t) + }) + t.Run("check cert with Unknown OCSP response", func(t *testing.T) { + client := testhelper.MockClient(testChain, []ocsp.ResponseStatus{ocsp.Unknown}, nil, true) + opts := Options{ + CertChain: revokableChain, + SigningTime: time.Now(), + HTTPClient: client, + } + + certResult := certCheckStatus(revokableChain[0], revokableChain[1], opts) + expectedCertResults := []*result.CertRevocationResult{{ + Result: result.ResultUnknown, + ServerResults: []*result.ServerResult{ + result.NewServerResult(result.ResultUnknown, ocspServer, UnknownStatusError{}), + }, + }} + validateEquivalentCertResults([]*result.CertRevocationResult{certResult}, expectedCertResults, t) + }) + t.Run("check OCSP revoked cert", func(t *testing.T) { + client := testhelper.MockClient(testChain, []ocsp.ResponseStatus{ocsp.Revoked}, nil, true) + opts := Options{ + CertChain: revokableChain, + SigningTime: time.Now(), + HTTPClient: client, + } + + certResult := certCheckStatus(revokableChain[0], revokableChain[1], opts) + expectedCertResults := []*result.CertRevocationResult{{ + Result: result.ResultRevoked, + ServerResults: []*result.ServerResult{ + result.NewServerResult(result.ResultRevoked, ocspServer, RevokedError{}), + }, + }} + validateEquivalentCertResults([]*result.CertRevocationResult{certResult}, expectedCertResults, t) + }) + t.Run("check OCSP future revoked cert", func(t *testing.T) { + revokedTime := time.Now().Add(time.Hour) + client := testhelper.MockClient(testChain, []ocsp.ResponseStatus{ocsp.Revoked}, &revokedTime, true) + opts := Options{ + CertChain: revokableChain, + SigningTime: time.Now(), + HTTPClient: client, + } + + certResult := certCheckStatus(revokableChain[0], revokableChain[1], opts) + expectedCertResults := []*result.CertRevocationResult{getOKCertResult(ocspServer)} + validateEquivalentCertResults([]*result.CertRevocationResult{certResult}, expectedCertResults, t) + }) +} + +func TestCheckStatusForSelfSignedCert(t *testing.T) { + selfSignedTuple := testhelper.GetRSASelfSignedSigningCertTuple("Notation revocation test self-signed cert") + client := testhelper.MockClient([]testhelper.RSACertTuple{selfSignedTuple}, []ocsp.ResponseStatus{ocsp.Good}, nil, true) + opts := Options{ + CertChain: []*x509.Certificate{selfSignedTuple.Cert}, + SigningTime: time.Now(), + HTTPClient: client, + } + + certResults, err := CheckStatus(opts) + if err != nil { + t.Errorf("Expected CheckStatus to succeed, but got error: %v", err) + } + expectedCertResults := []*result.CertRevocationResult{getRootCertResult()} + validateEquivalentCertResults(certResults, expectedCertResults, t) +} + +func TestCheckStatusForRootCert(t *testing.T) { + rootTuple := testhelper.GetRSARootCertificate() + client := testhelper.MockClient([]testhelper.RSACertTuple{rootTuple}, []ocsp.ResponseStatus{ocsp.Good}, nil, true) + opts := Options{ + CertChain: []*x509.Certificate{rootTuple.Cert}, + SigningTime: time.Now(), + HTTPClient: client, + } + + certResults, err := CheckStatus(opts) + expectedErr := result.InvalidChainError{Err: errors.New("invalid self-signed certificate. Error: certificate with subject \"CN=Notation Test RSA Root,O=Notary,L=Seattle,ST=WA,C=US\": if the basic constraints extension is present, the ca field must be set to false")} + if err == nil || err.Error() != expectedErr.Error() { + t.Errorf("Expected CheckStatus to fail with %v, but got: %v", expectedErr, err) + } + if certResults != nil { + t.Error("Expected certResults to be nil when there is an error") + } +} + +func TestCheckStatusForNonSelfSignedSingleCert(t *testing.T) { + certTuple := testhelper.GetRSALeafCertificate() + client := testhelper.MockClient([]testhelper.RSACertTuple{certTuple}, []ocsp.ResponseStatus{ocsp.Good}, nil, true) + opts := Options{ + CertChain: []*x509.Certificate{certTuple.Cert}, + SigningTime: time.Now(), + HTTPClient: client, + } + + certResults, err := CheckStatus(opts) + expectedErr := result.InvalidChainError{Err: errors.New("invalid self-signed certificate. subject: \"CN=Notation Test RSA Leaf Cert,O=Notary,L=Seattle,ST=WA,C=US\". Error: crypto/rsa: verification error")} + if err == nil || err.Error() != expectedErr.Error() { + t.Errorf("Expected CheckStatus to fail with %v, but got: %v", expectedErr, err) + } + if certResults != nil { + t.Error("Expected certResults to be nil when there is an error") + } +} + +func TestCheckStatusForChain(t *testing.T) { + zeroTime := time.Time{} + testChain := testhelper.GetRevokableRSAChain(6) + revokableChain := make([]*x509.Certificate, 6) + for i, tuple := range testChain { + revokableChain[i] = tuple.Cert + revokableChain[i].NotBefore = zeroTime + } + + t.Run("empty chain", func(t *testing.T) { + opts := Options{ + CertChain: []*x509.Certificate{}, + SigningTime: time.Now(), + HTTPClient: http.DefaultClient, + } + certResults, err := CheckStatus(opts) + expectedErr := result.InvalidChainError{Err: errors.New("chain does not contain any certificates")} + if err == nil || err.Error() != expectedErr.Error() { + t.Errorf("Expected CheckStatus to fail with %v, but got: %v", expectedErr, err) + } + if certResults != nil { + t.Error("Expected certResults to be nil when there is an error") + } + }) + t.Run("check non-revoked chain", func(t *testing.T) { + client := testhelper.MockClient(testChain, []ocsp.ResponseStatus{ocsp.Good}, nil, true) + opts := Options{ + CertChain: revokableChain, + SigningTime: time.Now(), + HTTPClient: client, + } + + certResults, err := CheckStatus(opts) + if err != nil { + t.Errorf("Expected CheckStatus to succeed, but got error: %v", err) + } + expectedCertResults := []*result.CertRevocationResult{ + getOKCertResult(revokableChain[0].OCSPServer[0]), + getOKCertResult(revokableChain[1].OCSPServer[0]), + getOKCertResult(revokableChain[2].OCSPServer[0]), + getOKCertResult(revokableChain[3].OCSPServer[0]), + getOKCertResult(revokableChain[4].OCSPServer[0]), + getRootCertResult(), + } + validateEquivalentCertResults(certResults, expectedCertResults, t) + }) + t.Run("check chain with 1 Unknown cert", func(t *testing.T) { + client := testhelper.MockClient(testChain, []ocsp.ResponseStatus{ocsp.Good, ocsp.Good, ocsp.Unknown, ocsp.Good}, nil, true) + // 3rd cert will be unknown, the rest will be good + opts := Options{ + CertChain: revokableChain, + SigningTime: time.Now(), + HTTPClient: client, + } + + certResults, err := CheckStatus(opts) + if err != nil { + t.Errorf("Expected CheckStatus to succeed, but got error: %v", err) + } + expectedCertResults := []*result.CertRevocationResult{ + getOKCertResult(revokableChain[0].OCSPServer[0]), + getOKCertResult(revokableChain[1].OCSPServer[0]), + { + Result: result.ResultUnknown, + ServerResults: []*result.ServerResult{ + result.NewServerResult(result.ResultUnknown, revokableChain[2].OCSPServer[0], UnknownStatusError{}), + }, + }, + getOKCertResult(revokableChain[3].OCSPServer[0]), + getOKCertResult(revokableChain[4].OCSPServer[0]), + getRootCertResult(), + } + validateEquivalentCertResults(certResults, expectedCertResults, t) + }) + t.Run("check OCSP with 1 revoked cert", func(t *testing.T) { + client := testhelper.MockClient(testChain, []ocsp.ResponseStatus{ocsp.Good, ocsp.Good, ocsp.Revoked, ocsp.Good}, nil, true) + // 3rd cert will be revoked, the rest will be good + opts := Options{ + CertChain: revokableChain, + SigningTime: time.Now(), + HTTPClient: client, + } + + certResults, err := CheckStatus(opts) + if err != nil { + t.Errorf("Expected CheckStatus to succeed, but got error: %v", err) + } + expectedCertResults := []*result.CertRevocationResult{ + getOKCertResult(revokableChain[0].OCSPServer[0]), + getOKCertResult(revokableChain[1].OCSPServer[0]), + { + Result: result.ResultRevoked, + ServerResults: []*result.ServerResult{ + result.NewServerResult(result.ResultRevoked, revokableChain[2].OCSPServer[0], RevokedError{}), + }, + }, + getOKCertResult(revokableChain[3].OCSPServer[0]), + getOKCertResult(revokableChain[4].OCSPServer[0]), + getRootCertResult(), + } + validateEquivalentCertResults(certResults, expectedCertResults, t) + }) + t.Run("check OCSP with 1 unknown and 1 revoked cert", func(t *testing.T) { + client := testhelper.MockClient(testChain, []ocsp.ResponseStatus{ocsp.Good, ocsp.Good, ocsp.Unknown, ocsp.Good, ocsp.Revoked, ocsp.Good}, nil, true) + // 3rd cert will be unknown, 5th will be revoked, the rest will be good + opts := Options{ + CertChain: revokableChain, + SigningTime: time.Now(), + HTTPClient: client, + } + + certResults, err := CheckStatus(opts) + if err != nil { + t.Errorf("Expected CheckStatus to succeed, but got error: %v", err) + } + expectedCertResults := []*result.CertRevocationResult{ + getOKCertResult(revokableChain[0].OCSPServer[0]), + getOKCertResult(revokableChain[1].OCSPServer[0]), + { + Result: result.ResultUnknown, + ServerResults: []*result.ServerResult{ + result.NewServerResult(result.ResultUnknown, revokableChain[2].OCSPServer[0], UnknownStatusError{}), + }, + }, + getOKCertResult(revokableChain[3].OCSPServer[0]), + { + Result: result.ResultRevoked, + ServerResults: []*result.ServerResult{ + result.NewServerResult(result.ResultRevoked, revokableChain[4].OCSPServer[0], RevokedError{}), + }, + }, + getRootCertResult(), + } + validateEquivalentCertResults(certResults, expectedCertResults, t) + }) + t.Run("check OCSP with 1 future revoked cert", func(t *testing.T) { + revokedTime := time.Now().Add(time.Hour) + client := testhelper.MockClient(testChain, []ocsp.ResponseStatus{ocsp.Good, ocsp.Good, ocsp.Revoked, ocsp.Good}, &revokedTime, true) + // 3rd cert will be revoked, the rest will be good + opts := Options{ + CertChain: revokableChain, + SigningTime: time.Now(), + HTTPClient: client, + } + + certResults, err := CheckStatus(opts) + if err != nil { + t.Errorf("Expected CheckStatus to succeed, but got error: %v", err) + } + expectedCertResults := []*result.CertRevocationResult{ + getOKCertResult(revokableChain[0].OCSPServer[0]), + getOKCertResult(revokableChain[1].OCSPServer[0]), + getOKCertResult(revokableChain[2].OCSPServer[0]), + getOKCertResult(revokableChain[3].OCSPServer[0]), + getOKCertResult(revokableChain[4].OCSPServer[0]), + getRootCertResult(), + } + validateEquivalentCertResults(certResults, expectedCertResults, t) + }) + t.Run("check OCSP with 1 unknown and 1 future revoked cert", func(t *testing.T) { + revokedTime := time.Now().Add(time.Hour) + client := testhelper.MockClient(testChain, []ocsp.ResponseStatus{ocsp.Good, ocsp.Good, ocsp.Unknown, ocsp.Good, ocsp.Revoked, ocsp.Good}, &revokedTime, true) + // 3rd cert will be unknown, 5th will be revoked, the rest will be good + opts := Options{ + CertChain: revokableChain, + SigningTime: time.Now(), + HTTPClient: client, + } + + certResults, err := CheckStatus(opts) + if err != nil { + t.Errorf("Expected CheckStatus to succeed, but got error: %v", err) + } + expectedCertResults := []*result.CertRevocationResult{ + getOKCertResult(revokableChain[0].OCSPServer[0]), + getOKCertResult(revokableChain[1].OCSPServer[0]), + { + Result: result.ResultUnknown, + ServerResults: []*result.ServerResult{ + result.NewServerResult(result.ResultUnknown, revokableChain[2].OCSPServer[0], UnknownStatusError{}), + }, + }, + getOKCertResult(revokableChain[3].OCSPServer[0]), + getOKCertResult(revokableChain[4].OCSPServer[0]), + getRootCertResult(), + } + validateEquivalentCertResults(certResults, expectedCertResults, t) + }) + t.Run("check OCSP with 1 revoked cert before signing time", func(t *testing.T) { + client := testhelper.MockClient(testChain, []ocsp.ResponseStatus{ocsp.Good, ocsp.Good, ocsp.Revoked, ocsp.Good}, nil, true) + // 3rd cert will be revoked, the rest will be good + opts := Options{ + CertChain: revokableChain, + SigningTime: time.Now().Add(time.Hour), + HTTPClient: client, + } + + certResults, err := CheckStatus(opts) + if err != nil { + t.Errorf("Expected CheckStatus to succeed, but got error: %v", err) + } + expectedCertResults := []*result.CertRevocationResult{ + getOKCertResult(revokableChain[0].OCSPServer[0]), + getOKCertResult(revokableChain[1].OCSPServer[0]), + { + Result: result.ResultRevoked, + ServerResults: []*result.ServerResult{ + result.NewServerResult(result.ResultRevoked, revokableChain[2].OCSPServer[0], RevokedError{}), + }, + }, + getOKCertResult(revokableChain[3].OCSPServer[0]), + getOKCertResult(revokableChain[4].OCSPServer[0]), + getRootCertResult(), + } + validateEquivalentCertResults(certResults, expectedCertResults, t) + }) + t.Run("check OCSP with 1 revoked cert after zero signing time", func(t *testing.T) { + revokedTime := time.Now().Add(time.Hour) + client := testhelper.MockClient(testChain, []ocsp.ResponseStatus{ocsp.Good, ocsp.Good, ocsp.Revoked, ocsp.Good}, &revokedTime, true) + // 3rd cert will be revoked, the rest will be good + opts := Options{ + CertChain: revokableChain, + SigningTime: zeroTime, + HTTPClient: client, + } + + if !zeroTime.IsZero() { + t.Errorf("exected zeroTime.IsZero() to be true") + } + + certResults, err := CheckStatus(opts) + if err != nil { + t.Errorf("Expected CheckStatus to succeed, but got error: %v", err) + } + expectedCertResults := []*result.CertRevocationResult{ + getOKCertResult(revokableChain[0].OCSPServer[0]), + getOKCertResult(revokableChain[1].OCSPServer[0]), + { + Result: result.ResultRevoked, + ServerResults: []*result.ServerResult{ + result.NewServerResult(result.ResultRevoked, revokableChain[2].OCSPServer[0], RevokedError{}), + }, + }, + getOKCertResult(revokableChain[3].OCSPServer[0]), + getOKCertResult(revokableChain[4].OCSPServer[0]), + getRootCertResult(), + } + validateEquivalentCertResults(certResults, expectedCertResults, t) + }) +} + +func TestCheckStatusErrors(t *testing.T) { + leafCertTuple := testhelper.GetRSALeafCertificate() + rootCertTuple := testhelper.GetRSARootCertificate() + noOCSPChain := []*x509.Certificate{leafCertTuple.Cert, rootCertTuple.Cert} + + revokableTuples := testhelper.GetRevokableRSAChain(3) + noRootChain := []*x509.Certificate{revokableTuples[0].Cert, revokableTuples[1].Cert} + backwardsChain := []*x509.Certificate{revokableTuples[2].Cert, revokableTuples[1].Cert, revokableTuples[0].Cert} + okChain := []*x509.Certificate{revokableTuples[0].Cert, revokableTuples[1].Cert, revokableTuples[2].Cert} + + expiredLeaf, _ := x509.ParseCertificate(revokableTuples[0].Cert.Raw) + expiredLeaf.IsCA = false + expiredLeaf.KeyUsage = x509.KeyUsageDigitalSignature + expiredLeaf.OCSPServer = []string{"http://example.com/expired_ocsp"} + expiredChain := []*x509.Certificate{expiredLeaf, revokableTuples[1].Cert, revokableTuples[2].Cert} + + noHTTPLeaf, _ := x509.ParseCertificate(revokableTuples[0].Cert.Raw) + noHTTPLeaf.IsCA = false + noHTTPLeaf.KeyUsage = x509.KeyUsageDigitalSignature + noHTTPLeaf.OCSPServer = []string{"ldap://ds.example.com:123/chain_ocsp/0"} + noHTTPChain := []*x509.Certificate{noHTTPLeaf, revokableTuples[1].Cert, revokableTuples[2].Cert} + + backwardsChainErr := result.InvalidChainError{Err: errors.New("leaf certificate with subject \"CN=Notation Test Revokable RSA Chain Cert Root,O=Notary,L=Seattle,ST=WA,C=US\" is self-signed. Certificate chain must not contain self-signed leaf certificate")} + chainRootErr := result.InvalidChainError{Err: errors.New("root certificate with subject \"CN=Notation Test Revokable RSA Chain Cert 2,O=Notary,L=Seattle,ST=WA,C=US\" is not self-signed. Certificate chain must end with a valid self-signed root certificate")} + expiredRespErr := GenericError{Err: errors.New("expired OCSP response")} + noHTTPErr := GenericError{Err: errors.New("OCSPServer protocol ldap is not supported")} + + t.Run("no OCSPServer specified", func(t *testing.T) { + opts := Options{ + CertChain: noOCSPChain, + SigningTime: time.Now(), + HTTPClient: http.DefaultClient, + } + certResults, err := CheckStatus(opts) + if err != nil { + t.Errorf("Expected CheckStatus to succeed, but got error: %v", err) + } + expectedCertResults := []*result.CertRevocationResult{ + { + Result: result.ResultNonRevokable, + ServerResults: []*result.ServerResult{ + result.NewServerResult(result.ResultNonRevokable, "", nil), + }, + }, + getRootCertResult(), + } + validateEquivalentCertResults(certResults, expectedCertResults, t) + }) + + t.Run("chain missing root", func(t *testing.T) { + opts := Options{ + CertChain: noRootChain, + SigningTime: time.Now(), + HTTPClient: http.DefaultClient, + } + certResults, err := CheckStatus(opts) + if err == nil || err.Error() != chainRootErr.Error() { + t.Errorf("Expected CheckStatus to fail with %v, but got: %v", chainRootErr, err) + } + if certResults != nil { + t.Error("Expected certResults to be nil when there is an error") + } + }) + + t.Run("backwards chain", func(t *testing.T) { + opts := Options{ + CertChain: backwardsChain, + SigningTime: time.Now(), + HTTPClient: http.DefaultClient, + } + certResults, err := CheckStatus(opts) + if err == nil || err.Error() != backwardsChainErr.Error() { + t.Errorf("Expected CheckStatus to fail with %v, but got: %v", backwardsChainErr, err) + } + if certResults != nil { + t.Error("Expected certResults to be nil when there is an error") + } + }) + + t.Run("timeout", func(t *testing.T) { + timeoutClient := &http.Client{Timeout: 1 * time.Nanosecond} + opts := Options{ + CertChain: okChain, + SigningTime: time.Now(), + HTTPClient: timeoutClient, + } + certResults, err := CheckStatus(opts) + if err != nil { + t.Errorf("Expected CheckStatus to succeed, but got error: %v", err) + } + expectedCertResults := []*result.CertRevocationResult{ + { + Result: result.ResultUnknown, + ServerResults: []*result.ServerResult{ + result.NewServerResult(result.ResultUnknown, okChain[0].OCSPServer[0], TimeoutError{}), + }, + }, + { + Result: result.ResultUnknown, + ServerResults: []*result.ServerResult{ + result.NewServerResult(result.ResultUnknown, okChain[1].OCSPServer[0], TimeoutError{}), + }, + }, + getRootCertResult(), + } + validateEquivalentCertResults(certResults, expectedCertResults, t) + }) + + t.Run("expired ocsp response", func(t *testing.T) { + client := testhelper.MockClient(revokableTuples, []ocsp.ResponseStatus{ocsp.Good}, nil, true) + opts := Options{ + CertChain: expiredChain, + SigningTime: time.Now(), + HTTPClient: client, + } + certResults, err := CheckStatus(opts) + if err != nil { + t.Errorf("Expected CheckStatus to succeed, but got error: %v", err) + } + expectedCertResults := []*result.CertRevocationResult{ + { + Result: result.ResultUnknown, + ServerResults: []*result.ServerResult{ + result.NewServerResult(result.ResultUnknown, expiredChain[0].OCSPServer[0], expiredRespErr), + }, + }, + getOKCertResult(expiredChain[1].OCSPServer[0]), + getRootCertResult(), + } + validateEquivalentCertResults(certResults, expectedCertResults, t) + }) + + t.Run("pkixNoCheck missing", func(t *testing.T) { + client := testhelper.MockClient(revokableTuples, []ocsp.ResponseStatus{ocsp.Good}, nil, false) + opts := Options{ + CertChain: okChain, + SigningTime: time.Now(), + HTTPClient: client, + } + + certResults, err := CheckStatus(opts) + if err != nil { + t.Errorf("Expected CheckStatus to succeed, but got error: %v", err) + } + expectedCertResults := []*result.CertRevocationResult{ + getOKCertResult(okChain[0].OCSPServer[0]), + getOKCertResult(okChain[1].OCSPServer[0]), + getRootCertResult(), + } + validateEquivalentCertResults(certResults, expectedCertResults, t) + }) + + t.Run("non-HTTP URI error", func(t *testing.T) { + client := testhelper.MockClient(revokableTuples, []ocsp.ResponseStatus{ocsp.Good}, nil, true) + opts := Options{ + CertChain: noHTTPChain, + SigningTime: time.Now(), + HTTPClient: client, + } + certResults, err := CheckStatus(opts) + if err != nil { + t.Errorf("Expected CheckStatus to succeed, but got error: %v", err) + } + expectedCertResults := []*result.CertRevocationResult{ + { + Result: result.ResultUnknown, + ServerResults: []*result.ServerResult{ + result.NewServerResult(result.ResultUnknown, noHTTPChain[0].OCSPServer[0], noHTTPErr), + }, + }, + getOKCertResult(noHTTPChain[1].OCSPServer[0]), + getRootCertResult(), + } + validateEquivalentCertResults(certResults, expectedCertResults, t) + }) +} + +func TestCheckOCSPInvalidChain(t *testing.T) { + revokableTuples := testhelper.GetRevokableRSAChain(4) + misorderedIntermediateTuples := []testhelper.RSACertTuple{revokableTuples[1], revokableTuples[0], revokableTuples[2], revokableTuples[3]} + misorderedIntermediateChain := []*x509.Certificate{revokableTuples[1].Cert, revokableTuples[0].Cert, revokableTuples[2].Cert, revokableTuples[3].Cert} + for i, cert := range misorderedIntermediateChain { + if i != (len(misorderedIntermediateChain) - 1) { + // Skip root which won't have an OCSP Server + cert.OCSPServer[0] = fmt.Sprintf("http://example.com/chain_ocsp/%d", i) + } + } + + missingIntermediateChain := []*x509.Certificate{revokableTuples[0].Cert, revokableTuples[2].Cert, revokableTuples[3].Cert} + for i, cert := range missingIntermediateChain { + if i != (len(missingIntermediateChain) - 1) { + // Skip root which won't have an OCSP Server + cert.OCSPServer[0] = fmt.Sprintf("http://example.com/chain_ocsp/%d", i) + } + } + + missingIntermediateErr := result.InvalidChainError{Err: errors.New("certificate with subject \"CN=Notation Test Revokable RSA Chain Cert 4,O=Notary,L=Seattle,ST=WA,C=US\" is not issued by \"CN=Notation Test Revokable RSA Chain Cert 2,O=Notary,L=Seattle,ST=WA,C=US\"")} + misorderedChainErr := result.InvalidChainError{Err: errors.New("invalid certificates or certificate with subject \"CN=Notation Test Revokable RSA Chain Cert 3,O=Notary,L=Seattle,ST=WA,C=US\" is not issued by \"CN=Notation Test Revokable RSA Chain Cert 4,O=Notary,L=Seattle,ST=WA,C=US\". Error: x509: invalid signature: parent certificate cannot sign this kind of certificate")} + + t.Run("chain missing intermediate", func(t *testing.T) { + client := testhelper.MockClient(revokableTuples, []ocsp.ResponseStatus{ocsp.Good}, nil, true) + opts := Options{ + CertChain: missingIntermediateChain, + SigningTime: time.Now(), + HTTPClient: client, + } + certResults, err := CheckStatus(opts) + if err == nil || err.Error() != missingIntermediateErr.Error() { + t.Errorf("Expected CheckStatus to fail with %v, but got: %v", missingIntermediateErr, err) + } + if certResults != nil { + t.Error("Expected certResults to be nil when there is an error") + } + }) + + t.Run("chain out of order", func(t *testing.T) { + client := testhelper.MockClient(misorderedIntermediateTuples, []ocsp.ResponseStatus{ocsp.Good}, nil, true) + opts := Options{ + CertChain: misorderedIntermediateChain, + SigningTime: time.Now(), + HTTPClient: client, + } + certResults, err := CheckStatus(opts) + if err == nil || err.Error() != misorderedChainErr.Error() { + t.Errorf("Expected CheckStatus to fail with %v, but got: %v", misorderedChainErr, err) + } + if certResults != nil { + t.Error("Expected certResults to be nil when there is an error") + } + }) +} diff --git a/revocation/result/errors.go b/revocation/result/errors.go new file mode 100644 index 00000000..3e27167b --- /dev/null +++ b/revocation/result/errors.go @@ -0,0 +1,20 @@ +// Package result provides general objects that are used across revocation +package result + +import ( + "fmt" +) + +// InvalidChainError is returned when the certificate chain does not meet the +// requirements for a valid certificate chain +type InvalidChainError struct { + Err error +} + +func (e InvalidChainError) Error() string { + msg := "invalid chain: expected chain to be correct and complete" + if e.Err != nil { + return fmt.Sprintf("%s: %v", msg, e.Err) + } + return msg +} diff --git a/revocation/result/errors_test.go b/revocation/result/errors_test.go new file mode 100644 index 00000000..dc3c9268 --- /dev/null +++ b/revocation/result/errors_test.go @@ -0,0 +1,26 @@ +package result + +import ( + "errors" + "testing" +) + +func TestInvalidChainError(t *testing.T) { + t.Run("without_inner_error", func(t *testing.T) { + err := &InvalidChainError{} + expectedMsg := "invalid chain: expected chain to be correct and complete" + + if err.Error() != expectedMsg { + t.Errorf("Expected %v but got %v", expectedMsg, err.Error()) + } + }) + + t.Run("inner_error", func(t *testing.T) { + err := &InvalidChainError{Err: errors.New("inner error")} + expectedMsg := "invalid chain: expected chain to be correct and complete: inner error" + + if err.Error() != expectedMsg { + t.Errorf("Expected %v but got %v", expectedMsg, err.Error()) + } + }) +} diff --git a/revocation/result/results.go b/revocation/result/results.go new file mode 100644 index 00000000..ba0f229f --- /dev/null +++ b/revocation/result/results.go @@ -0,0 +1,90 @@ +// Package result provides general objects that are used across revocation +package result + +import "strconv" + +// Result is a type of enumerated value to help characterize errors. It can be +// OK, Unknown, or Revoked +type Result int + +const ( + // ResultUnknown is a Result that indicates that some error other than a + // revocation was encountered during the revocation check + ResultUnknown Result = iota + // ResultOK is a Result that indicates that the revocation check resulted in no + // important errors + ResultOK + // ResultNonRevokable is a Result that indicates that the certificate cannot be + // checked for revocation. This may be a result of no OCSP servers being + // specified, the cert is a root certificate, or other related situations. + ResultNonRevokable + // ResultRevoked is a Result that indicates that at least one certificate was + // revoked when performing a revocation check on the certificate chain + ResultRevoked +) + +// String provides a conversion from a Result to a string +func (r Result) String() string { + switch r { + case ResultOK: + return "OK" + case ResultNonRevokable: + return "NonRevokable" + case ResultUnknown: + return "Unknown" + case ResultRevoked: + return "Revoked" + default: + return "invalid result with value " + strconv.Itoa(int(r)) + } +} + +// ServerResult encapsulates the result for a single server for a single +// certificate in the chain +type ServerResult struct { + // Result of revocation for this server (Unknown if there is an error which + // prevents the retrieval of a valid status) + Result Result + + // Server is the URI associated with this result. If no server is associated + // with the result (e.g. it is a root certificate or no OCSPServers are + // specified), then this will be an empty string ("") + Server string + + // Error is set if there is an error associated with the revocation check + // to this server + Error error +} + +// NewServerResult creates a ServerResult object from its individual parts: a +// Result, a string for the server, and an error +func NewServerResult(result Result, server string, err error) *ServerResult { + return &ServerResult{ + Result: result, + Server: server, + Error: err, + } +} + +// CertRevocationResult encapsulates the result for a single certificate in the +// chain as well as the results from individual servers associated with this +// certificate +type CertRevocationResult struct { + // Result of revocation for a specific cert in the chain + // + // If there are multiple ServerResults, this is because no responses were + // able to be retrieved, leaving each ServerResult with a Result of Unknown. + // Thus, in the case of more than one ServerResult, this will be ResultUnknown + Result Result + + // An array of results for each server associated with the certificate. + // The length will be either 1 or the number of OCSPServers for the cert. + // + // If the length is 1, then a valid status was able to be retrieved. Only + // this server result is contained. Any errors for other servers are + // discarded in favor of this valid response. + // + // Otherwise, every server specified had some error that prevented the + // status from being retrieved. These are all contained here for evaluation + ServerResults []*ServerResult +} diff --git a/revocation/result/results_test.go b/revocation/result/results_test.go new file mode 100644 index 00000000..2310dd04 --- /dev/null +++ b/revocation/result/results_test.go @@ -0,0 +1,52 @@ +package result + +import ( + "errors" + "testing" +) + +func TestResultString(t *testing.T) { + t.Run("ok", func(t *testing.T) { + if ResultOK.String() != "OK" { + t.Errorf("Expected %s but got %s", "OK", ResultOK.String()) + } + }) + t.Run("non-revokable", func(t *testing.T) { + if ResultNonRevokable.String() != "NonRevokable" { + t.Errorf("Expected %s but got %s", "NonRevokable", ResultNonRevokable.String()) + } + }) + t.Run("unknown", func(t *testing.T) { + if ResultUnknown.String() != "Unknown" { + t.Errorf("Expected %s but got %s", "Unknown", ResultUnknown.String()) + } + }) + t.Run("revoked", func(t *testing.T) { + if ResultRevoked.String() != "Revoked" { + t.Errorf("Expected %s but got %s", "Revoked", ResultRevoked.String()) + } + }) + t.Run("invalid result", func(t *testing.T) { + if Result(4).String() != "invalid result with value 4" { + t.Errorf("Expected %s but got %s", "invalid result with value 4", Result(4).String()) + } + }) +} + +func TestNewServerResult(t *testing.T) { + expectedR := &ServerResult{ + Result: ResultNonRevokable, + Server: "test server", + Error: errors.New("test error"), + } + r := NewServerResult(expectedR.Result, expectedR.Server, expectedR.Error) + if r.Result != expectedR.Result { + t.Errorf("Expected %s but got %s", expectedR.Result, r.Result) + } + if r.Server != expectedR.Server { + t.Errorf("Expected %s but got %s", expectedR.Server, r.Server) + } + if r.Error != expectedR.Error { + t.Errorf("Expected %v but got %v", expectedR.Error, r.Error) + } +} diff --git a/revocation/revocation.go b/revocation/revocation.go new file mode 100644 index 00000000..20a03819 --- /dev/null +++ b/revocation/revocation.go @@ -0,0 +1,52 @@ +// Package Revocation provides methods for checking the revocation status of a +// certificate chain +package revocation + +import ( + "crypto/x509" + "errors" + "net/http" + "time" + + "github.com/notaryproject/notation-core-go/revocation/ocsp" + "github.com/notaryproject/notation-core-go/revocation/result" +) + +// Revocation is an interface that specifies methods used for revocation checking +type Revocation interface { + // Validate checks the revocation status for a certificate chain using OCSP + // and returns an array of CertRevocationResults that contain the results + // and any errors that are encountered during the process + Validate(certChain []*x509.Certificate, signingTime time.Time) ([]*result.CertRevocationResult, error) +} + +// revocation is an internal struct used for revocation checking +type revocation struct { + httpClient *http.Client +} + +// New constructs a revocation object +func New(httpClient *http.Client) (Revocation, error) { + if httpClient == nil { + return nil, errors.New("invalid input: a non-nil httpClient must be specified") + } + return &revocation{ + httpClient: httpClient, + }, nil +} + +// Validate checks the revocation status for a certificate chain using OCSP and +// returns an array of CertRevocationResults that contain the results and any +// errors that are encountered during the process +// +// TODO: add CRL support +// https://github.com/notaryproject/notation-core-go/issues/125 +func (r *revocation) Validate(certChain []*x509.Certificate, signingTime time.Time) ([]*result.CertRevocationResult, error) { + return ocsp.CheckStatus(ocsp.Options{ + CertChain: certChain, + SigningTime: signingTime, + HTTPClient: r.httpClient, + }) + // TODO: add CRL support + // https://github.com/notaryproject/notation-core-go/issues/125 +} diff --git a/revocation/revocation_test.go b/revocation/revocation_test.go new file mode 100644 index 00000000..48755535 --- /dev/null +++ b/revocation/revocation_test.go @@ -0,0 +1,665 @@ +package revocation + +import ( + "crypto/x509" + "errors" + "fmt" + "net/http" + "testing" + "time" + + revocationocsp "github.com/notaryproject/notation-core-go/revocation/ocsp" + "github.com/notaryproject/notation-core-go/revocation/result" + "github.com/notaryproject/notation-core-go/testhelper" + "golang.org/x/crypto/ocsp" +) + +func validateEquivalentCertResults(certResults, expectedCertResults []*result.CertRevocationResult, t *testing.T) { + if len(certResults) != len(expectedCertResults) { + t.Errorf("Length of certResults (%d) did not match expected length (%d)", len(certResults), len(expectedCertResults)) + return + } + for i, certResult := range certResults { + if certResult.Result != expectedCertResults[i].Result { + t.Errorf("Expected certResults[%d].Result to be %s, but got %s", i, expectedCertResults[i].Result, certResult.Result) + } + if len(certResult.ServerResults) != len(expectedCertResults[i].ServerResults) { + t.Errorf("Length of certResults[%d].ServerResults (%d) did not match expected length (%d)", i, len(certResult.ServerResults), len(expectedCertResults[i].ServerResults)) + return + } + for j, serverResult := range certResult.ServerResults { + if serverResult.Result != expectedCertResults[i].ServerResults[j].Result { + t.Errorf("Expected certResults[%d].ServerResults[%d].Result to be %s, but got %s", i, j, expectedCertResults[i].ServerResults[j].Result, serverResult.Result) + } + if serverResult.Server != expectedCertResults[i].ServerResults[j].Server { + t.Errorf("Expected certResults[%d].ServerResults[%d].Server to be %s, but got %s", i, j, expectedCertResults[i].ServerResults[j].Server, serverResult.Server) + } + if serverResult.Error == nil { + if expectedCertResults[i].ServerResults[j].Error == nil { + continue + } + t.Errorf("certResults[%d].ServerResults[%d].Error was nil, but expected %v", i, j, expectedCertResults[i].ServerResults[j].Error) + } else if expectedCertResults[i].ServerResults[j].Error == nil { + t.Errorf("Unexpected error for certResults[%d].ServerResults[%d].Error: %v", i, j, serverResult.Error) + } else if serverResult.Error.Error() != expectedCertResults[i].ServerResults[j].Error.Error() { + t.Errorf("Expected certResults[%d].ServerResults[%d].Error to be %v, but got %v", i, j, expectedCertResults[i].ServerResults[j].Error, serverResult.Error) + } + } + } +} + +func getOKCertResult(server string) *result.CertRevocationResult { + return &result.CertRevocationResult{ + Result: result.ResultOK, + ServerResults: []*result.ServerResult{ + result.NewServerResult(result.ResultOK, server, nil), + }, + } +} + +func getRootCertResult() *result.CertRevocationResult { + return &result.CertRevocationResult{ + Result: result.ResultNonRevokable, + ServerResults: []*result.ServerResult{ + result.NewServerResult(result.ResultNonRevokable, "", nil), + }, + } +} + +func TestNew(t *testing.T) { + r, err := New(nil) + expectedError := errors.New("invalid input: a non-nil httpClient must be specified") + if r != nil && err.Error() != expectedError.Error() { + t.Errorf("Expected New(nil) to fail with %v and %v, but received %v and %v", nil, expectedError, r, err) + } + + client := http.DefaultClient + r, err = New(client) + if err != nil { + t.Errorf("Expected to succeed with default client, but received error %v", err) + } + revR, ok := r.(*revocation) + if !ok { + t.Error("Expected New to create an object matching the internal revocation struct") + } else if revR.httpClient != client { + t.Errorf("Expected New to set client to %v, but it was set to %v", client, revR.httpClient) + } +} + +func TestCheckRevocationStatusForSingleCert(t *testing.T) { + revokableCertTuple := testhelper.GetRevokableRSALeafCertificate() + revokableIssuerTuple := testhelper.GetRSARootCertificate() + revokableChain := []*x509.Certificate{revokableCertTuple.Cert, revokableIssuerTuple.Cert} + testChain := []testhelper.RSACertTuple{revokableCertTuple, revokableIssuerTuple} + + t.Run("check non-revoked cert", func(t *testing.T) { + client := testhelper.MockClient(testChain, []ocsp.ResponseStatus{ocsp.Good}, nil, true) + r, err := New(client) + if err != nil { + t.Errorf("Expected successful creation of revocation, but received error: %v", err) + } + + certResults, err := r.Validate(revokableChain, time.Now()) + if err != nil { + t.Errorf("Expected CheckStatus to succeed, but got error: %v", err) + } + expectedCertResults := []*result.CertRevocationResult{getOKCertResult(revokableChain[0].OCSPServer[0]), getRootCertResult()} + validateEquivalentCertResults(certResults, expectedCertResults, t) + }) + t.Run("check cert with Unknown OCSP response", func(t *testing.T) { + client := testhelper.MockClient(testChain, []ocsp.ResponseStatus{ocsp.Unknown}, nil, true) + r, err := New(client) + if err != nil { + t.Errorf("Expected successful creation of revocation, but received error: %v", err) + } + + certResults, err := r.Validate(revokableChain, time.Now()) + if err != nil { + t.Errorf("Expected CheckStatus to succeed, but got error: %v", err) + } + expectedCertResults := []*result.CertRevocationResult{ + { + Result: result.ResultUnknown, + ServerResults: []*result.ServerResult{ + result.NewServerResult(result.ResultUnknown, revokableChain[0].OCSPServer[0], revocationocsp.UnknownStatusError{}), + }, + }, + getRootCertResult(), + } + validateEquivalentCertResults(certResults, expectedCertResults, t) + }) + t.Run("check OCSP revoked cert", func(t *testing.T) { + client := testhelper.MockClient(testChain, []ocsp.ResponseStatus{ocsp.Revoked}, nil, true) + r, err := New(client) + if err != nil { + t.Errorf("Expected successful creation of revocation, but received error: %v", err) + } + + certResults, err := r.Validate(revokableChain, time.Now()) + if err != nil { + t.Errorf("Expected CheckStatus to succeed, but got error: %v", err) + } + expectedCertResults := []*result.CertRevocationResult{ + { + Result: result.ResultRevoked, + ServerResults: []*result.ServerResult{ + result.NewServerResult(result.ResultRevoked, revokableChain[0].OCSPServer[0], revocationocsp.RevokedError{}), + }, + }, + getRootCertResult(), + } + validateEquivalentCertResults(certResults, expectedCertResults, t) + }) + t.Run("check OCSP future revoked cert", func(t *testing.T) { + revokedTime := time.Now().Add(time.Hour) + client := testhelper.MockClient(testChain, []ocsp.ResponseStatus{ocsp.Revoked}, &revokedTime, true) + r, err := New(client) + if err != nil { + t.Errorf("Expected successful creation of revocation, but received error: %v", err) + } + + certResults, err := r.Validate(revokableChain, time.Now()) + if err != nil { + t.Errorf("Expected CheckStatus to succeed, but got error: %v", err) + } + expectedCertResults := []*result.CertRevocationResult{ + getOKCertResult(revokableChain[0].OCSPServer[0]), + getRootCertResult(), + } + validateEquivalentCertResults(certResults, expectedCertResults, t) + }) +} + +func TestCheckRevocationStatusForSelfSignedCert(t *testing.T) { + selfSignedTuple := testhelper.GetRSASelfSignedSigningCertTuple("Notation revocation test self-signed cert") + client := testhelper.MockClient([]testhelper.RSACertTuple{selfSignedTuple}, []ocsp.ResponseStatus{ocsp.Good}, nil, true) + r, err := New(client) + if err != nil { + t.Errorf("Expected successful creation of revocation, but received error: %v", err) + } + certResults, err := r.Validate([]*x509.Certificate{selfSignedTuple.Cert}, time.Now()) + if err != nil { + t.Errorf("Expected CheckStatus to succeed, but got error: %v", err) + } + expectedCertResults := []*result.CertRevocationResult{getRootCertResult()} + validateEquivalentCertResults(certResults, expectedCertResults, t) +} + +func TestCheckRevocationStatusForRootCert(t *testing.T) { + rootTuple := testhelper.GetRSARootCertificate() + client := testhelper.MockClient([]testhelper.RSACertTuple{rootTuple}, []ocsp.ResponseStatus{ocsp.Good}, nil, true) + r, err := New(client) + if err != nil { + t.Errorf("Expected successful creation of revocation, but received error: %v", err) + } + + certResults, err := r.Validate([]*x509.Certificate{rootTuple.Cert}, time.Now()) + expectedErr := result.InvalidChainError{Err: errors.New("invalid self-signed certificate. Error: certificate with subject \"CN=Notation Test RSA Root,O=Notary,L=Seattle,ST=WA,C=US\": if the basic constraints extension is present, the ca field must be set to false")} + if err == nil || err.Error() != expectedErr.Error() { + t.Errorf("Expected Validate to fail with %v, but got: %v", expectedErr, err) + } + if certResults != nil { + t.Error("Expected certResults to be nil when there is an error") + } +} + +func TestCheckRevocationStatusForChain(t *testing.T) { + zeroTime := time.Time{} + testChain := testhelper.GetRevokableRSAChain(6) + revokableChain := make([]*x509.Certificate, 6) + for i, tuple := range testChain { + revokableChain[i] = tuple.Cert + revokableChain[i].NotBefore = zeroTime + } + + t.Run("empty chain", func(t *testing.T) { + r, err := New(&http.Client{Timeout: 5 * time.Second}) + if err != nil { + t.Errorf("Expected successful creation of revocation, but received error: %v", err) + } + certResults, err := r.Validate([]*x509.Certificate{}, time.Now()) + expectedErr := result.InvalidChainError{Err: errors.New("chain does not contain any certificates")} + if err == nil || err.Error() != expectedErr.Error() { + t.Errorf("Expected CheckStatus to fail with %v, but got: %v", expectedErr, err) + } + if certResults != nil { + t.Error("Expected certResults to be nil when there is an error") + } + }) + t.Run("check non-revoked chain", func(t *testing.T) { + client := testhelper.MockClient(testChain, []ocsp.ResponseStatus{ocsp.Good}, nil, true) + r, err := New(client) + if err != nil { + t.Errorf("Expected successful creation of revocation, but received error: %v", err) + } + + certResults, err := r.Validate(revokableChain, time.Now()) + if err != nil { + t.Errorf("Expected CheckStatus to succeed, but got error: %v", err) + } + expectedCertResults := []*result.CertRevocationResult{ + getOKCertResult(revokableChain[0].OCSPServer[0]), + getOKCertResult(revokableChain[1].OCSPServer[0]), + getOKCertResult(revokableChain[2].OCSPServer[0]), + getOKCertResult(revokableChain[3].OCSPServer[0]), + getOKCertResult(revokableChain[4].OCSPServer[0]), + getRootCertResult(), + } + validateEquivalentCertResults(certResults, expectedCertResults, t) + }) + t.Run("check chain with 1 Unknown cert", func(t *testing.T) { + client := testhelper.MockClient(testChain, []ocsp.ResponseStatus{ocsp.Good, ocsp.Good, ocsp.Unknown, ocsp.Good}, nil, true) + // 3rd cert will be unknown, the rest will be good + r, err := New(client) + if err != nil { + t.Errorf("Expected successful creation of revocation, but received error: %v", err) + } + + certResults, err := r.Validate(revokableChain, time.Now()) + if err != nil { + t.Errorf("Expected CheckStatus to succeed, but got error: %v", err) + } + expectedCertResults := []*result.CertRevocationResult{ + getOKCertResult(revokableChain[0].OCSPServer[0]), + getOKCertResult(revokableChain[1].OCSPServer[0]), + { + Result: result.ResultUnknown, + ServerResults: []*result.ServerResult{ + result.NewServerResult(result.ResultUnknown, revokableChain[2].OCSPServer[0], revocationocsp.UnknownStatusError{}), + }, + }, + getOKCertResult(revokableChain[3].OCSPServer[0]), + getOKCertResult(revokableChain[4].OCSPServer[0]), + getRootCertResult(), + } + validateEquivalentCertResults(certResults, expectedCertResults, t) + }) + t.Run("check OCSP with 1 revoked cert", func(t *testing.T) { + client := testhelper.MockClient(testChain, []ocsp.ResponseStatus{ocsp.Good, ocsp.Good, ocsp.Revoked, ocsp.Good}, nil, true) + // 3rd cert will be revoked, the rest will be good + r, err := New(client) + if err != nil { + t.Errorf("Expected successful creation of revocation, but received error: %v", err) + } + + certResults, err := r.Validate(revokableChain, time.Now()) + if err != nil { + t.Errorf("Expected CheckStatus to succeed, but got error: %v", err) + } + expectedCertResults := []*result.CertRevocationResult{ + getOKCertResult(revokableChain[0].OCSPServer[0]), + getOKCertResult(revokableChain[1].OCSPServer[0]), + { + Result: result.ResultRevoked, + ServerResults: []*result.ServerResult{ + result.NewServerResult(result.ResultRevoked, revokableChain[2].OCSPServer[0], revocationocsp.RevokedError{}), + }, + }, + getOKCertResult(revokableChain[3].OCSPServer[0]), + getOKCertResult(revokableChain[4].OCSPServer[0]), + getRootCertResult(), + } + validateEquivalentCertResults(certResults, expectedCertResults, t) + }) + t.Run("check OCSP with 1 unknown and 1 revoked cert", func(t *testing.T) { + client := testhelper.MockClient(testChain, []ocsp.ResponseStatus{ocsp.Good, ocsp.Good, ocsp.Unknown, ocsp.Good, ocsp.Revoked, ocsp.Good}, nil, true) + // 3rd cert will be unknown, 5th will be revoked, the rest will be good + r, err := New(client) + if err != nil { + t.Errorf("Expected successful creation of revocation, but received error: %v", err) + } + + certResults, err := r.Validate(revokableChain, time.Now()) + if err != nil { + t.Errorf("Expected CheckStatus to succeed, but got error: %v", err) + } + expectedCertResults := []*result.CertRevocationResult{ + getOKCertResult(revokableChain[0].OCSPServer[0]), + getOKCertResult(revokableChain[1].OCSPServer[0]), + { + Result: result.ResultUnknown, + ServerResults: []*result.ServerResult{ + result.NewServerResult(result.ResultUnknown, revokableChain[2].OCSPServer[0], revocationocsp.UnknownStatusError{}), + }, + }, + getOKCertResult(revokableChain[3].OCSPServer[0]), + { + Result: result.ResultRevoked, + ServerResults: []*result.ServerResult{ + result.NewServerResult(result.ResultRevoked, revokableChain[4].OCSPServer[0], revocationocsp.RevokedError{}), + }, + }, + getRootCertResult(), + } + validateEquivalentCertResults(certResults, expectedCertResults, t) + }) + t.Run("check OCSP with 1 future revoked cert", func(t *testing.T) { + revokedTime := time.Now().Add(time.Hour) + client := testhelper.MockClient(testChain, []ocsp.ResponseStatus{ocsp.Good, ocsp.Good, ocsp.Revoked, ocsp.Good}, &revokedTime, true) + // 3rd cert will be future revoked, the rest will be good + r, err := New(client) + if err != nil { + t.Errorf("Expected successful creation of revocation, but received error: %v", err) + } + + certResults, err := r.Validate(revokableChain, time.Now()) + if err != nil { + t.Errorf("Expected CheckStatus to succeed, but got error: %v", err) + } + expectedCertResults := []*result.CertRevocationResult{ + getOKCertResult(revokableChain[0].OCSPServer[0]), + getOKCertResult(revokableChain[1].OCSPServer[0]), + getOKCertResult(revokableChain[2].OCSPServer[0]), + getOKCertResult(revokableChain[3].OCSPServer[0]), + getOKCertResult(revokableChain[4].OCSPServer[0]), + getRootCertResult(), + } + validateEquivalentCertResults(certResults, expectedCertResults, t) + }) + t.Run("check OCSP with 1 unknown and 1 future revoked cert", func(t *testing.T) { + revokedTime := time.Now().Add(time.Hour) + client := testhelper.MockClient(testChain, []ocsp.ResponseStatus{ocsp.Good, ocsp.Good, ocsp.Unknown, ocsp.Good, ocsp.Revoked, ocsp.Good}, &revokedTime, true) + // 3rd cert will be unknown, 5th will be future revoked, the rest will be good + r, err := New(client) + if err != nil { + t.Errorf("Expected successful creation of revocation, but received error: %v", err) + } + + certResults, err := r.Validate(revokableChain, time.Now()) + if err != nil { + t.Errorf("Expected CheckStatus to succeed, but got error: %v", err) + } + expectedCertResults := []*result.CertRevocationResult{ + getOKCertResult(revokableChain[0].OCSPServer[0]), + getOKCertResult(revokableChain[1].OCSPServer[0]), + { + Result: result.ResultUnknown, + ServerResults: []*result.ServerResult{ + result.NewServerResult(result.ResultUnknown, revokableChain[2].OCSPServer[0], revocationocsp.UnknownStatusError{}), + }, + }, + getOKCertResult(revokableChain[3].OCSPServer[0]), + getOKCertResult(revokableChain[4].OCSPServer[0]), + getRootCertResult(), + } + validateEquivalentCertResults(certResults, expectedCertResults, t) + }) + t.Run("check OCSP with 1 revoked cert before signing time", func(t *testing.T) { + client := testhelper.MockClient(testChain, []ocsp.ResponseStatus{ocsp.Good, ocsp.Good, ocsp.Revoked, ocsp.Good}, nil, true) + // 3rd cert will be revoked, the rest will be good + r, err := New(client) + if err != nil { + t.Errorf("Expected successful creation of revocation, but received error: %v", err) + } + + certResults, err := r.Validate(revokableChain, time.Now().Add(time.Hour)) + if err != nil { + t.Errorf("Expected CheckStatus to succeed, but got error: %v", err) + } + expectedCertResults := []*result.CertRevocationResult{ + getOKCertResult(revokableChain[0].OCSPServer[0]), + getOKCertResult(revokableChain[1].OCSPServer[0]), + { + Result: result.ResultRevoked, + ServerResults: []*result.ServerResult{ + result.NewServerResult(result.ResultRevoked, revokableChain[2].OCSPServer[0], revocationocsp.RevokedError{}), + }, + }, + getOKCertResult(revokableChain[3].OCSPServer[0]), + getOKCertResult(revokableChain[4].OCSPServer[0]), + getRootCertResult(), + } + validateEquivalentCertResults(certResults, expectedCertResults, t) + }) + t.Run("check OCSP with 1 revoked cert after zero signing time", func(t *testing.T) { + revokedTime := time.Now().Add(time.Hour) + client := testhelper.MockClient(testChain, []ocsp.ResponseStatus{ocsp.Good, ocsp.Good, ocsp.Revoked, ocsp.Good}, &revokedTime, true) + // 3rd cert will be revoked, the rest will be good + + if !zeroTime.IsZero() { + t.Errorf("exected zeroTime.IsZero() to be true") + } + + r, err := New(client) + if err != nil { + t.Errorf("Expected successful creation of revocation, but received error: %v", err) + } + + certResults, err := r.Validate(revokableChain, time.Now().Add(time.Hour)) + if err != nil { + t.Errorf("Expected CheckStatus to succeed, but got error: %v", err) + } + expectedCertResults := []*result.CertRevocationResult{ + getOKCertResult(revokableChain[0].OCSPServer[0]), + getOKCertResult(revokableChain[1].OCSPServer[0]), + { + Result: result.ResultRevoked, + ServerResults: []*result.ServerResult{ + result.NewServerResult(result.ResultRevoked, revokableChain[2].OCSPServer[0], revocationocsp.RevokedError{}), + }, + }, + getOKCertResult(revokableChain[3].OCSPServer[0]), + getOKCertResult(revokableChain[4].OCSPServer[0]), + getRootCertResult(), + } + validateEquivalentCertResults(certResults, expectedCertResults, t) + }) +} + +func TestCheckRevocationErrors(t *testing.T) { + leafCertTuple := testhelper.GetRSALeafCertificate() + rootCertTuple := testhelper.GetRSARootCertificate() + noOCSPChain := []*x509.Certificate{leafCertTuple.Cert, rootCertTuple.Cert} + + revokableTuples := testhelper.GetRevokableRSAChain(3) + noRootChain := []*x509.Certificate{revokableTuples[0].Cert, revokableTuples[1].Cert} + backwardsChain := []*x509.Certificate{revokableTuples[2].Cert, revokableTuples[1].Cert, revokableTuples[0].Cert} + okChain := []*x509.Certificate{revokableTuples[0].Cert, revokableTuples[1].Cert, revokableTuples[2].Cert} + + expiredLeaf, _ := x509.ParseCertificate(revokableTuples[0].Cert.Raw) + expiredLeaf.IsCA = false + expiredLeaf.KeyUsage = x509.KeyUsageDigitalSignature + expiredLeaf.OCSPServer = []string{"http://example.com/expired_ocsp"} + expiredChain := []*x509.Certificate{expiredLeaf, revokableTuples[1].Cert, revokableTuples[2].Cert} + + noHTTPLeaf, _ := x509.ParseCertificate(revokableTuples[0].Cert.Raw) + noHTTPLeaf.IsCA = false + noHTTPLeaf.KeyUsage = x509.KeyUsageDigitalSignature + noHTTPLeaf.OCSPServer = []string{"ldap://ds.example.com:123/chain_ocsp/0"} + noHTTPChain := []*x509.Certificate{noHTTPLeaf, revokableTuples[1].Cert, revokableTuples[2].Cert} + + backwardsChainErr := result.InvalidChainError{Err: errors.New("leaf certificate with subject \"CN=Notation Test Revokable RSA Chain Cert Root,O=Notary,L=Seattle,ST=WA,C=US\" is self-signed. Certificate chain must not contain self-signed leaf certificate")} + chainRootErr := result.InvalidChainError{Err: errors.New("root certificate with subject \"CN=Notation Test Revokable RSA Chain Cert 2,O=Notary,L=Seattle,ST=WA,C=US\" is not self-signed. Certificate chain must end with a valid self-signed root certificate")} + expiredRespErr := revocationocsp.GenericError{Err: errors.New("expired OCSP response")} + noHTTPErr := revocationocsp.GenericError{Err: errors.New("OCSPServer protocol ldap is not supported")} + + r, err := New(&http.Client{Timeout: 5 * time.Second}) + if err != nil { + t.Errorf("Expected successful creation of revocation, but received error: %v", err) + } + + t.Run("no OCSPServer specified", func(t *testing.T) { + certResults, err := r.Validate(noOCSPChain, time.Now()) + if err != nil { + t.Errorf("Expected CheckStatus to succeed, but got error: %v", err) + } + expectedCertResults := []*result.CertRevocationResult{ + { + Result: result.ResultNonRevokable, + ServerResults: []*result.ServerResult{ + result.NewServerResult(result.ResultNonRevokable, "", nil), + }, + }, + getRootCertResult(), + } + validateEquivalentCertResults(certResults, expectedCertResults, t) + }) + + t.Run("chain missing root", func(t *testing.T) { + certResults, err := r.Validate(noRootChain, time.Now()) + if err == nil || err.Error() != chainRootErr.Error() { + t.Errorf("Expected Validate to fail with %v, but got: %v", chainRootErr, err) + } + if certResults != nil { + t.Error("Expected certResults to be nil when there is an error") + } + }) + + t.Run("backwards chain", func(t *testing.T) { + certResults, err := r.Validate(backwardsChain, time.Now()) + if err == nil || err.Error() != backwardsChainErr.Error() { + t.Errorf("Expected Validate to fail with %v, but got: %v", backwardsChainErr, err) + } + if certResults != nil { + t.Error("Expected certResults to be nil when there is an error") + } + }) + + t.Run("timeout", func(t *testing.T) { + timeoutClient := &http.Client{Timeout: 1 * time.Nanosecond} + timeoutR, err := New(timeoutClient) + if err != nil { + t.Errorf("Expected successful creation of revocation, but received error: %v", err) + } + certResults, err := timeoutR.Validate(okChain, time.Now()) + if err != nil { + t.Errorf("Expected CheckStatus to succeed, but got error: %v", err) + } + expectedCertResults := []*result.CertRevocationResult{ + { + Result: result.ResultUnknown, + ServerResults: []*result.ServerResult{ + result.NewServerResult(result.ResultUnknown, okChain[0].OCSPServer[0], revocationocsp.TimeoutError{}), + }, + }, + { + Result: result.ResultUnknown, + ServerResults: []*result.ServerResult{ + result.NewServerResult(result.ResultUnknown, okChain[1].OCSPServer[0], revocationocsp.TimeoutError{}), + }, + }, + getRootCertResult(), + } + validateEquivalentCertResults(certResults, expectedCertResults, t) + }) + + t.Run("expired ocsp response", func(t *testing.T) { + client := testhelper.MockClient(revokableTuples, []ocsp.ResponseStatus{ocsp.Good}, nil, true) + expiredR, err := New(client) + if err != nil { + t.Errorf("Expected successful creation of revocation, but received error: %v", err) + } + certResults, err := expiredR.Validate(expiredChain, time.Now()) + if err != nil { + t.Errorf("Expected CheckStatus to succeed, but got error: %v", err) + } + expectedCertResults := []*result.CertRevocationResult{ + { + Result: result.ResultUnknown, + ServerResults: []*result.ServerResult{ + result.NewServerResult(result.ResultUnknown, expiredChain[0].OCSPServer[0], expiredRespErr), + }, + }, + getOKCertResult(expiredChain[1].OCSPServer[0]), + getRootCertResult(), + } + validateEquivalentCertResults(certResults, expectedCertResults, t) + }) + + t.Run("OCSP pkixNoCheck missing", func(t *testing.T) { + client := testhelper.MockClient(revokableTuples, []ocsp.ResponseStatus{ocsp.Good}, nil, false) + r, err := New(client) + if err != nil { + t.Errorf("Expected successful creation of revocation, but received error: %v", err) + } + + certResults, err := r.Validate(okChain, time.Now()) + if err != nil { + t.Errorf("Expected CheckStatus to succeed, but got error: %v", err) + } + expectedCertResults := []*result.CertRevocationResult{ + getOKCertResult(okChain[0].OCSPServer[0]), + getOKCertResult(okChain[1].OCSPServer[0]), + getRootCertResult(), + } + validateEquivalentCertResults(certResults, expectedCertResults, t) + }) + + t.Run("non-HTTP URI error", func(t *testing.T) { + client := testhelper.MockClient(revokableTuples, []ocsp.ResponseStatus{ocsp.Good}, nil, true) + r, err := New(client) + if err != nil { + t.Errorf("Expected successful creation of revocation, but received error: %v", err) + } + certResults, err := r.Validate(noHTTPChain, time.Now()) + if err != nil { + t.Errorf("Expected CheckStatus to succeed, but got error: %v", err) + } + expectedCertResults := []*result.CertRevocationResult{ + { + Result: result.ResultUnknown, + ServerResults: []*result.ServerResult{ + result.NewServerResult(result.ResultUnknown, noHTTPChain[0].OCSPServer[0], noHTTPErr), + }, + }, + getOKCertResult(noHTTPChain[1].OCSPServer[0]), + getRootCertResult(), + } + validateEquivalentCertResults(certResults, expectedCertResults, t) + }) +} + +func TestCheckRevocationInvalidChain(t *testing.T) { + revokableTuples := testhelper.GetRevokableRSAChain(4) + misorderedIntermediateTuples := []testhelper.RSACertTuple{revokableTuples[1], revokableTuples[0], revokableTuples[2], revokableTuples[3]} + misorderedIntermediateChain := []*x509.Certificate{revokableTuples[1].Cert, revokableTuples[0].Cert, revokableTuples[2].Cert, revokableTuples[3].Cert} + for i, cert := range misorderedIntermediateChain { + if i != (len(misorderedIntermediateChain) - 1) { + // Skip root which won't have an OCSP Server + cert.OCSPServer[0] = fmt.Sprintf("http://example.com/chain_ocsp/%d", i) + } + } + + missingIntermediateChain := []*x509.Certificate{revokableTuples[0].Cert, revokableTuples[2].Cert, revokableTuples[3].Cert} + for i, cert := range missingIntermediateChain { + if i != (len(missingIntermediateChain) - 1) { + // Skip root which won't have an OCSP Server + cert.OCSPServer[0] = fmt.Sprintf("http://example.com/chain_ocsp/%d", i) + } + } + + missingIntermediateErr := result.InvalidChainError{Err: errors.New("certificate with subject \"CN=Notation Test Revokable RSA Chain Cert 4,O=Notary,L=Seattle,ST=WA,C=US\" is not issued by \"CN=Notation Test Revokable RSA Chain Cert 2,O=Notary,L=Seattle,ST=WA,C=US\"")} + misorderedChainErr := result.InvalidChainError{Err: errors.New("invalid certificates or certificate with subject \"CN=Notation Test Revokable RSA Chain Cert 3,O=Notary,L=Seattle,ST=WA,C=US\" is not issued by \"CN=Notation Test Revokable RSA Chain Cert 4,O=Notary,L=Seattle,ST=WA,C=US\". Error: x509: invalid signature: parent certificate cannot sign this kind of certificate")} + + t.Run("chain missing intermediate", func(t *testing.T) { + client := testhelper.MockClient(revokableTuples, []ocsp.ResponseStatus{ocsp.Good}, nil, true) + r, err := New(client) + if err != nil { + t.Errorf("Expected successful creation of revocation, but received error: %v", err) + } + + certResults, err := r.Validate(missingIntermediateChain, time.Now()) + if err == nil || err.Error() != missingIntermediateErr.Error() { + t.Errorf("Expected Validate to fail with %v, but got: %v", missingIntermediateErr, err) + } + if certResults != nil { + t.Error("Expected certResults to be nil when there is an error") + } + }) + + t.Run("chain out of order", func(t *testing.T) { + client := testhelper.MockClient(misorderedIntermediateTuples, []ocsp.ResponseStatus{ocsp.Good}, nil, true) + r, err := New(client) + if err != nil { + t.Errorf("Expected successful creation of revocation, but received error: %v", err) + } + + certResults, err := r.Validate(misorderedIntermediateChain, time.Now()) + if err == nil || err.Error() != misorderedChainErr.Error() { + t.Errorf("Expected Validate to fail with %v, but got: %v", misorderedChainErr, err) + } + if certResults != nil { + t.Error("Expected certResults to be nil when there is an error") + } + }) +} diff --git a/signature/types.go b/signature/types.go index 63b59eec..6009943c 100644 --- a/signature/types.go +++ b/signature/types.go @@ -145,3 +145,18 @@ func (signerInfo *SignerInfo) ExtendedAttribute(key string) (Attribute, error) { } return Attribute{}, errors.New("key not in ExtendedAttributes") } + +// AuthenticSigningTime returns the authentic signing time +func (signerInfo *SignerInfo) AuthenticSigningTime() (time.Time, error) { + switch signerInfo.SignedAttributes.SigningScheme { + case SigningSchemeX509SigningAuthority: + return signerInfo.SignedAttributes.SigningTime, nil + case SigningSchemeX509: + if len(signerInfo.UnsignedAttributes.TimestampSignature) > 0 { + // TODO: Add TSA support for AutheticSigningTime + // https://github.com/notaryproject/notation-core-go/issues/38 + return time.Time{}, errors.New("TSA checking has not been implemented") + } + } + return time.Time{}, errors.New("authenticSigningTime not found") +} diff --git a/testhelper/certificatetest.go b/testhelper/certificatetest.go index b0c91840..87c23808 100644 --- a/testhelper/certificatetest.go +++ b/testhelper/certificatetest.go @@ -9,6 +9,7 @@ import ( "crypto/rsa" "crypto/x509" "crypto/x509/pkix" + "fmt" "math/big" mrand "math/rand" "strconv" @@ -19,6 +20,7 @@ import ( var ( rsaRoot RSACertTuple rsaLeaf RSACertTuple + revokableRSALeaf RSACertTuple rsaLeafWithoutEKU RSACertTuple ecdsaRoot ECCertTuple ecdsaLeaf ECCertTuple @@ -51,6 +53,26 @@ func GetRSALeafCertificate() RSACertTuple { return rsaLeaf } +// GetRevokableRSALeafCertificate returns leaf certificate that specifies a local OCSP server signed using RSA algorithm +func GetRevokableRSALeafCertificate() RSACertTuple { + setupCertificates() + return revokableRSALeaf +} + +// GetRevokableRSAChain returns a chain of certificates that specify a local OCSP server signed using RSA algorithm +func GetRevokableRSAChain(size int) []RSACertTuple { + setupCertificates() + chain := make([]RSACertTuple, size) + chain[size-1] = getRevokableRSARootChainCertTuple("Notation Test Revokable RSA Chain Cert Root", size-1) + for i := size - 2; i > 0; i-- { + chain[i] = getRevokableRSAChainCertTuple(fmt.Sprintf("Notation Test Revokable RSA Chain Cert %d", size-i), &chain[i+1], i) + } + if size > 1 { + chain[0] = getRevokableRSALeafChainCertTuple(fmt.Sprintf("Notation Test Revokable RSA Chain Cert %d", size), &chain[1], 0) + } + return chain +} + // GetRSALeafCertificateWithoutEKU returns leaf certificate without EKU signed using RSA algorithm func GetRSALeafCertificateWithoutEKU() RSACertTuple { setupCertificates() @@ -93,6 +115,7 @@ func setupCertificates() { setupCertificatesOnce.Do(func() { rsaRoot = getRSACertTuple("Notation Test RSA Root", nil) rsaLeaf = getRSACertTuple("Notation Test RSA Leaf Cert", &rsaRoot) + revokableRSALeaf = getRevokableRSACertTuple("Notation Test Revokable RSA Leaf Cert", &rsaRoot) rsaLeafWithoutEKU = getRSACertWithoutEKUTuple("Notation Test RSA Leaf without EKU Cert", &rsaRoot) ecdsaRoot = getECCertTuple("Notation Test EC Root", nil) ecdsaLeaf = getECCertTuple("Notation Test EC Leaf Cert", &ecdsaRoot) @@ -111,6 +134,40 @@ func getRSACertTuple(cn string, issuer *RSACertTuple) RSACertTuple { return GetRSACertTupleWithPK(pk, cn, issuer) } +func getRevokableRSACertTuple(cn string, issuer *RSACertTuple) RSACertTuple { + template := getCertTemplate(issuer == nil, true, cn) + template.OCSPServer = []string{"http://example.com/ocsp"} + return getRSACertTupleWithTemplate(template, issuer.PrivateKey, issuer) +} + +func getRevokableRSAChainCertTuple(cn string, previous *RSACertTuple, index int) RSACertTuple { + template := getCertTemplate(previous == nil, true, cn) + template.BasicConstraintsValid = true + template.IsCA = true + template.KeyUsage = x509.KeyUsageCertSign + template.OCSPServer = []string{fmt.Sprintf("http://example.com/chain_ocsp/%d", index)} + return getRSACertTupleWithTemplate(template, previous.PrivateKey, previous) +} + +func getRevokableRSARootChainCertTuple(cn string, pathLen int) RSACertTuple { + pk, _ := rsa.GenerateKey(rand.Reader, 3072) + template := getCertTemplate(true, true, cn) + template.BasicConstraintsValid = true + template.IsCA = true + template.KeyUsage = x509.KeyUsageCertSign + template.MaxPathLen = pathLen + return getRSACertTupleWithTemplate(template, pk, nil) +} + +func getRevokableRSALeafChainCertTuple(cn string, issuer *RSACertTuple, index int) RSACertTuple { + template := getCertTemplate(false, true, cn) + template.BasicConstraintsValid = true + template.IsCA = false + template.KeyUsage = x509.KeyUsageDigitalSignature + template.OCSPServer = []string{fmt.Sprintf("http://example.com/chain_ocsp/%d", index)} + return getRSACertTupleWithTemplate(template, issuer.PrivateKey, issuer) +} + func getRSACertWithoutEKUTuple(cn string, issuer *RSACertTuple) RSACertTuple { pk, _ := rsa.GenerateKey(rand.Reader, 3072) template := getCertTemplate(issuer == nil, false, cn) diff --git a/testhelper/httptest.go b/testhelper/httptest.go new file mode 100644 index 00000000..5579f3f0 --- /dev/null +++ b/testhelper/httptest.go @@ -0,0 +1,119 @@ +// Package testhelper implements utility routines required for writing unit tests. +// The testhelper should only be used in unit tests. +package testhelper + +import ( + "bytes" + "crypto/x509/pkix" + "encoding/asn1" + "errors" + "fmt" + "io" + "net/http" + "regexp" + "strconv" + "strings" + "time" + + "golang.org/x/crypto/ocsp" +) + +type spyRoundTripper struct { + backupTransport http.RoundTripper + certChain []RSACertTuple + desiredOCSPStatuses []ocsp.ResponseStatus + revokedTime *time.Time + validPKIXNoCheck bool +} + +func (s spyRoundTripper) roundTripResponse(index int, expired bool) (*http.Response, error) { + // Verify index of cert in chain + if index == (len(s.certChain) - 1) { + return nil, errors.New("OCSP cannot be performed on root") + } else if index > (len(s.certChain) - 1) { + return nil, errors.New("index exceeded chain size") + } + + // Get desired status for index + var status ocsp.ResponseStatus + if index < len(s.desiredOCSPStatuses) { + status = s.desiredOCSPStatuses[index] + } else if len(s.desiredOCSPStatuses) == 0 { + status = ocsp.Good + } else { + // Use last status from passed statuses + status = s.desiredOCSPStatuses[len(s.desiredOCSPStatuses)-1] + } + + // Create template for ocsp response + var nextUpdate time.Time + if expired { + nextUpdate = time.Now().Add(-1 * time.Hour) + } else { + nextUpdate = time.Now().Add(time.Hour) + } + template := ocsp.Response{ + Status: int(status), + SerialNumber: s.certChain[index].Cert.SerialNumber, + NextUpdate: nextUpdate, + } + if status == ocsp.Revoked { + if s.revokedTime != nil { + template.RevokedAt = *s.revokedTime + generalizedTime, _ := asn1.MarshalWithParams(*s.revokedTime, "generalized") + template.ExtraExtensions = []pkix.Extension{{Id: asn1.ObjectIdentifier{2, 5, 29, 24}, Critical: false, Value: generalizedTime}} + } else { + template.RevokedAt = time.Now().Add(-1 * time.Hour) + } + template.RevocationReason = ocsp.Unspecified + } + if s.validPKIXNoCheck { + template.ExtraExtensions = append(template.ExtraExtensions, pkix.Extension{Id: asn1.ObjectIdentifier{1, 3, 6, 1, 5, 5, 7, 48, 1, 5}, Critical: false, Value: nil}) + } + + // Create ocsp response + response, err := ocsp.CreateResponse(s.certChain[index].Cert, s.certChain[index+1].Cert, template, s.certChain[index].PrivateKey) + if err == nil { + return &http.Response{ + Body: io.NopCloser(bytes.NewBuffer(response)), + StatusCode: http.StatusOK, + }, nil + } else { + return nil, err + } +} + +func (s spyRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + if match, _ := regexp.MatchString("^\\/ocsp.*", req.URL.Path); match { + return s.roundTripResponse(0, false) + + } else if match, _ := regexp.MatchString("^\\/expired_ocsp.*", req.URL.Path); match { + return s.roundTripResponse(0, true) + + } else if match, _ := regexp.MatchString("^\\/chain_ocsp.*", req.URL.Path); match { + // this url works with the revokable chain, which has url structure /chain_ocsp/ or /chain_ocsp// + index, err := strconv.Atoi(strings.Split(req.URL.Path, "/")[2]) + if err != nil { + return nil, err + } + return s.roundTripResponse(index, false) + + } else { + fmt.Printf("%s did not match a specified path, using default transport", req.URL.Path) + return s.backupTransport.RoundTrip(req) + } +} + +// Creates a mock HTTP Client (more accurately, a spy) that intercepts requests to /issuer and /ocsp endpoints +// The client's responses are dependent on the cert and desiredOCSPStatus +func MockClient(certChain []RSACertTuple, desiredOCSPStatuses []ocsp.ResponseStatus, revokedTime *time.Time, validPKIXNoCheck bool) *http.Client { + return &http.Client{ + Transport: spyRoundTripper{ + backupTransport: http.DefaultTransport, + certChain: certChain, + desiredOCSPStatuses: desiredOCSPStatuses, + revokedTime: revokedTime, + validPKIXNoCheck: validPKIXNoCheck, + }, + } +}